start-command 0.35.4 → 0.37.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +16 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +48 -7
  5. package/src/lib/args-parser-queries.js +14 -0
  6. package/src/lib/args-parser.js +32 -1
  7. package/src/lib/atomic-write.js +34 -0
  8. package/src/lib/attached-diagnostics.js +90 -0
  9. package/src/lib/cgroup-memory.js +43 -11
  10. package/src/lib/command-builder.js +43 -0
  11. package/src/lib/cpu-penalty-monitor.js +226 -0
  12. package/src/lib/cpu-penalty.js +175 -0
  13. package/src/lib/detached-finalize.js +16 -0
  14. package/src/lib/docker-cleanup.js +35 -7
  15. package/src/lib/docker-command-handoff.js +75 -0
  16. package/src/lib/docker-labels.js +33 -0
  17. package/src/lib/docker-resource-limits.js +28 -0
  18. package/src/lib/docker-resource-options.js +180 -0
  19. package/src/lib/docker-runtime-args.js +23 -0
  20. package/src/lib/docker-snapshot-safety.js +164 -0
  21. package/src/lib/execution-recovery.js +153 -30
  22. package/src/lib/execution-resume-all.js +11 -0
  23. package/src/lib/execution-resume.js +314 -54
  24. package/src/lib/execution-store.js +25 -121
  25. package/src/lib/exit-evidence.js +123 -0
  26. package/src/lib/exit-reason.js +24 -6
  27. package/src/lib/failure-handler.js +15 -5
  28. package/src/lib/isolation.js +48 -7
  29. package/src/lib/launch-owner.js +25 -0
  30. package/src/lib/launch-persistence.js +54 -0
  31. package/src/lib/log-sanitizer.js +267 -0
  32. package/src/lib/log-uploader.js +38 -19
  33. package/src/lib/query-commands.js +18 -6
  34. package/src/lib/resume-resources.js +88 -0
  35. package/src/lib/shell-utils.js +67 -17
  36. package/src/lib/status-formatter.js +28 -0
  37. package/src/lib/store-lock.js +133 -0
  38. package/src/lib/usage.js +16 -1
  39. package/test/ci-workflow-invariants.js +15 -14
  40. package/test/docker-command-handoff.js +107 -0
  41. package/test/docker-runtime-options.js +10 -0
  42. package/test/docker-snapshot-safety.js +106 -0
  43. package/test/execution-resume.js +19 -11
  44. package/test/exit-reason.js +6 -1
  45. package/test/failure-handler.js +11 -0
  46. package/test/isolation.js +5 -4
  47. package/test/javascript-first-ci.js +216 -0
  48. package/test/log-sanitizer.js +306 -0
  49. package/test/regression-174.js +3 -1
  50. package/test/regression-176.js +7 -0
  51. package/test/regression-180.js +17 -10
  52. package/test/regression-182.js +7 -3
  53. package/test/regression-185.js +4 -9
  54. package/test/regression-187.js +11 -2
  55. package/test/regression-189.js +224 -0
  56. package/test/regression-190.js +111 -0
  57. package/test/regression-191.js +160 -0
  58. package/test/regression-192.js +66 -0
  59. package/test/regression-193.js +251 -0
  60. package/test/regression-194.js +182 -0
  61. package/test/regression-198.js +281 -0
  62. package/test/regression-199.js +79 -0
  63. package/test/regression-201.js +25 -0
  64. package/test/regression-202.js +92 -0
  65. package/test/regression-91.js +23 -20
  66. package/test/shared-golden.js +54 -0
  67. package/test/status-query.js +97 -15
@@ -24,6 +24,7 @@ const path = require('path');
24
24
  const os = require('os');
25
25
  const { execFileSync, spawnSync } = require('child_process');
26
26
  const crypto = require('crypto');
27
+ const { atomicWrite } = require('./atomic-write');
27
28
 
28
29
  // Synchronous wrapper using Bun's native ESM support
29
30
  // This works because Bun handles ESM/CJS interop
@@ -51,8 +52,7 @@ const DEFAULT_APP_FOLDER = path.join(os.homedir(), '.start-command');
51
52
  const LINO_DB_FILE = 'executions.lino';
52
53
  const LINKS_DB_FILE = 'executions.links';
53
54
  const LOCK_FILE = 'executions.lock';
54
- const LOCK_TIMEOUT_MS = 30000; // 30 second timeout for lock acquisition
55
- const LOCK_STALE_MS = 60000; // Consider lock stale after 60 seconds
55
+ const { LockManager } = require('./store-lock');
56
56
 
57
57
  /**
58
58
  * Execution status enumeration
@@ -128,6 +128,7 @@ class ExecutionRecord {
128
128
  this.shell = options.shell || process.env.SHELL || '/bin/sh';
129
129
  this.platform = options.platform || process.platform;
130
130
  this.options = options.options || {};
131
+ this.cpuPenalty = options.cpuPenalty;
131
132
  }
132
133
 
133
134
  /**
@@ -154,6 +155,9 @@ class ExecutionRecord {
154
155
  startTime: this.startTime,
155
156
  endTime: this.endTime,
156
157
  };
158
+ if (this.cpuPenalty) {
159
+ obj.cpuPenalty = this.cpuPenalty;
160
+ }
157
161
  if (this.attempt) {
158
162
  obj.attempt = this.attempt;
159
163
  }
@@ -210,123 +214,6 @@ class ExecutionRecord {
210
214
  }
211
215
  }
212
216
 
213
- /**
214
- * File-based lock manager
215
- */
216
- class LockManager {
217
- constructor(lockFilePath) {
218
- this.lockFilePath = lockFilePath;
219
- this.lockAcquired = false;
220
- }
221
-
222
- /**
223
- * Acquire an exclusive lock
224
- * @param {number} timeout - Maximum time to wait for lock in ms
225
- * @returns {boolean} True if lock acquired
226
- */
227
- acquire(timeout = LOCK_TIMEOUT_MS) {
228
- const startTime = Date.now();
229
-
230
- while (Date.now() - startTime < timeout) {
231
- try {
232
- // Check if existing lock is stale
233
- if (fs.existsSync(this.lockFilePath)) {
234
- const lockData = this.readLockFile();
235
- if (lockData && this.isLockStale(lockData)) {
236
- // Remove stale lock
237
- fs.unlinkSync(this.lockFilePath);
238
- }
239
- }
240
-
241
- // Try to create lock file exclusively
242
- const lockData = {
243
- pid: process.pid,
244
- timestamp: Date.now(),
245
- hostname: os.hostname(),
246
- };
247
-
248
- fs.writeFileSync(this.lockFilePath, JSON.stringify(lockData), {
249
- flag: 'wx', // Fail if file exists
250
- });
251
-
252
- this.lockAcquired = true;
253
- return true;
254
- } catch (err) {
255
- if (err.code === 'EEXIST') {
256
- // Lock file exists, wait and retry
257
- this.sleep(100);
258
- continue;
259
- }
260
- throw err;
261
- }
262
- }
263
-
264
- return false;
265
- }
266
-
267
- /**
268
- * Release the lock
269
- */
270
- release() {
271
- if (this.lockAcquired) {
272
- try {
273
- fs.unlinkSync(this.lockFilePath);
274
- } catch {
275
- // Ignore errors during release
276
- }
277
- this.lockAcquired = false;
278
- }
279
- }
280
-
281
- /**
282
- * Read lock file data
283
- */
284
- readLockFile() {
285
- try {
286
- const content = fs.readFileSync(this.lockFilePath, 'utf8');
287
- return JSON.parse(content);
288
- } catch {
289
- return null;
290
- }
291
- }
292
-
293
- /**
294
- * Check if lock is stale
295
- */
296
- isLockStale(lockData) {
297
- if (!lockData || !lockData.timestamp) {
298
- return true;
299
- }
300
-
301
- // Check if lock is too old
302
- if (Date.now() - lockData.timestamp > LOCK_STALE_MS) {
303
- return true;
304
- }
305
-
306
- // Check if the process that holds the lock is still running
307
- if (lockData.pid && lockData.hostname === os.hostname()) {
308
- try {
309
- process.kill(lockData.pid, 0); // Signal 0 just checks if process exists
310
- return false; // Process exists, lock is valid
311
- } catch {
312
- return true; // Process doesn't exist, lock is stale
313
- }
314
- }
315
-
316
- return false;
317
- }
318
-
319
- /**
320
- * Simple sleep function
321
- */
322
- sleep(ms) {
323
- const end = Date.now() + ms;
324
- while (Date.now() < end) {
325
- // Busy wait (not ideal but works for short durations)
326
- }
327
- }
328
- }
329
-
330
217
  /**
331
218
  * Check if clink is installed
332
219
  * @returns {boolean}
@@ -412,7 +299,7 @@ class ExecutionStore {
412
299
  writeLinoRecords(records) {
413
300
  const data = records.map((r) => r.toObject());
414
301
  const content = encodeSync(data);
415
- fs.writeFileSync(this.linoDbPath, content, 'utf8');
302
+ atomicWrite(this.linoDbPath, content);
416
303
  this.log(`Wrote ${records.length} records to lino file`);
417
304
  }
418
305
 
@@ -529,7 +416,7 @@ class ExecutionStore {
529
416
  * @param {ExecutionRecord} record
530
417
  * @returns {boolean}
531
418
  */
532
- save(record) {
419
+ save(record, previous = null) {
533
420
  const lock = new LockManager(this.lockFilePath);
534
421
 
535
422
  if (!lock.acquire()) {
@@ -542,6 +429,16 @@ class ExecutionStore {
542
429
 
543
430
  // Find existing record index
544
431
  const existingIndex = records.findIndex((r) => r.uuid === record.uuid);
432
+ if (previous) {
433
+ const current = records[existingIndex];
434
+ if (
435
+ !current ||
436
+ require('./launch-owner').hasActiveLaunch(current) ||
437
+ (current.attempt?.number || 0) !== (previous.attempt?.number || 0)
438
+ ) {
439
+ throw new Error('A different resume already reserved this execution');
440
+ }
441
+ }
545
442
 
546
443
  if (existingIndex >= 0) {
547
444
  // Update existing record
@@ -565,6 +462,10 @@ class ExecutionStore {
565
462
  }
566
463
  }
567
464
 
465
+ reserveLaunch(record, previous) {
466
+ return this.save(record, previous);
467
+ }
468
+
568
469
  // Merge watcher metadata under the database lock without replacing terminal state.
569
470
  patchAttempt(uuid, number, fields) {
570
471
  const lock = new LockManager(this.lockFilePath);
@@ -689,6 +590,9 @@ class ExecutionStore {
689
590
  const staleRecords = [];
690
591
 
691
592
  for (const record of executingRecords) {
593
+ if (require('./launch-owner').hasActiveLaunch(record)) {
594
+ continue;
595
+ }
692
596
  let isStale = false;
693
597
  let reason = '';
694
598
 
@@ -0,0 +1,123 @@
1
+ /** Attempt-scoped evidence, independent of Docker's sticky OOMKilled flag. */
2
+ const { spawnSync } = require('child_process');
3
+ const { shellQuote } = require('./isolation-log-utils');
4
+ const MAIN_OOM = 'Exit evidence: main-oom (recent cgroup oom_kill delta)';
5
+ const DAEMON_RESTART =
6
+ 'Exit evidence: docker-daemon-restart (Docker service journal)';
7
+
8
+ function recentOomDelta(sample, finishedAt) {
9
+ const fields = String(sample || '')
10
+ .trim()
11
+ .split(/\s+/);
12
+ const changed = Number(fields[4]),
13
+ observed = Number(fields[5]);
14
+ const finished = Date.parse(finishedAt) / 1000;
15
+ return (
16
+ changed > 0 &&
17
+ Number.isFinite(finished) &&
18
+ changed <= observed &&
19
+ Math.abs(finished - changed) <= 3 &&
20
+ Math.abs(finished - observed) <= 3
21
+ );
22
+ }
23
+
24
+ function daemonRestartEvidence(journal, containerId) {
25
+ const lines = String(journal || '').split('\n');
26
+ const exited = lines.some((line) =>
27
+ /Main process exited|Stopping Docker|Starting Docker|Daemon shutdown complete/.test(
28
+ line
29
+ )
30
+ );
31
+ const forced =
32
+ containerId &&
33
+ lines.some(
34
+ (line) =>
35
+ line.includes(containerId) &&
36
+ /failed to exit|using the force|force.kill/i.test(line)
37
+ );
38
+ return Boolean(exited && forced);
39
+ }
40
+
41
+ function collectExitEvidence(
42
+ { containerName, sample, finishedAt, exitCode, oomKilled },
43
+ runner
44
+ ) {
45
+ const run =
46
+ runner ||
47
+ ((cmd, args) => {
48
+ const r = spawnSync(cmd, args, { encoding: 'utf8' });
49
+ return { success: r.status === 0, stdout: r.stdout || '' };
50
+ });
51
+ // A local journal cannot diagnose a remote daemon. Require a local socket.
52
+ const local =
53
+ !process.env.DOCKER_HOST || process.env.DOCKER_HOST.startsWith('unix://');
54
+ if (
55
+ local &&
56
+ Number(exitCode) === 137 &&
57
+ Number.isFinite(Date.parse(finishedAt))
58
+ ) {
59
+ const docker = require('./docker-cleanup').getDockerCommand();
60
+ const id = run(docker, ['inspect', '-f', '{{.Id}}', containerName]);
61
+ if (id.success && /^[a-f0-9]{64}$/.test(id.stdout.trim())) {
62
+ const end = Date.parse(finishedAt);
63
+ const journal = run('journalctl', [
64
+ '-u',
65
+ 'docker.service',
66
+ '--since',
67
+ new Date(end - 60000).toISOString(),
68
+ '--until',
69
+ new Date(end + 1000).toISOString(),
70
+ '--no-pager',
71
+ '-o',
72
+ 'cat',
73
+ ]);
74
+ if (
75
+ journal.success &&
76
+ daemonRestartEvidence(journal.stdout, id.stdout.trim())
77
+ ) {
78
+ return DAEMON_RESTART;
79
+ }
80
+ }
81
+ }
82
+ if (
83
+ (oomKilled === true || oomKilled === 'true') &&
84
+ (Number(exitCode) === 137 || Number(exitCode) < 0) &&
85
+ recentOomDelta(sample, finishedAt)
86
+ ) {
87
+ return MAIN_OOM;
88
+ }
89
+ return 'Exit evidence: unavailable (no attributed exit-time OOM or daemon restart evidence)';
90
+ }
91
+
92
+ function fromLog(log) {
93
+ const lines = String(log || '').split('\n');
94
+ const last = lines.filter((line) => line.startsWith('Exit evidence:')).at(-1);
95
+ return { mainOom: last === MAIN_OOM, daemonRestart: last === DAEMON_RESTART };
96
+ }
97
+
98
+ function buildExitEvidenceSnippet(containerName, quotedLogPath) {
99
+ return `${shellQuote(process.execPath)} ${shellQuote(__filename)} ${shellQuote(containerName)} "$__start_command_cgroup" "$__start_command_finished" "$__start_command_exit" "$__start_command_oom" >> ${quotedLogPath} 2>/dev/null`;
100
+ }
101
+
102
+ module.exports = {
103
+ MAIN_OOM,
104
+ DAEMON_RESTART,
105
+ recentOomDelta,
106
+ daemonRestartEvidence,
107
+ collectExitEvidence,
108
+ fromLog,
109
+ buildExitEvidenceSnippet,
110
+ };
111
+ if (require.main === module) {
112
+ const [containerName, sample, finishedAt, exitCode, oomKilled] =
113
+ process.argv.slice(2);
114
+ console.log(
115
+ collectExitEvidence({
116
+ containerName,
117
+ sample,
118
+ finishedAt,
119
+ exitCode,
120
+ oomKilled,
121
+ })
122
+ );
123
+ }
@@ -181,8 +181,16 @@ function resolveMemoryExhaustion(input) {
181
181
  return null;
182
182
  }
183
183
 
184
+ const evidence =
185
+ input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
186
+ if (evidence.daemonRestart) {
187
+ return null;
188
+ }
184
189
  const marker = detectMemoryMarker(input.logTail);
185
- if (marker) {
190
+ if (
191
+ marker &&
192
+ !(marker.reason.includes('kernel-oom-killer') && !evidence.mainOom)
193
+ ) {
186
194
  return { memoryExhausted: true, memoryExhaustedReason: marker.line };
187
195
  }
188
196
  if (input.oomKilled === true && isOomKillOfCommand(input)) {
@@ -230,9 +238,9 @@ function isOomKillOfCommand(input) {
230
238
  if (!input) {
231
239
  return false;
232
240
  }
233
- const observed =
234
- input.oomKilled === true || cgroupOomKills(input.cgroupMemory) > 0;
235
- if (!observed) {
241
+ const evidence =
242
+ input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
243
+ if (!evidence.mainOom || evidence.daemonRestart) {
236
244
  return false;
237
245
  }
238
246
  const { exitCode } = input;
@@ -318,8 +326,16 @@ function resolveExitReason(input) {
318
326
  return null;
319
327
  }
320
328
 
329
+ const evidence =
330
+ input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
331
+ if (evidence.daemonRestart) {
332
+ return 'killed (docker daemon restart)';
333
+ }
321
334
  const fromLog = detectExitReason(input.logTail);
322
- if (fromLog) {
335
+ if (
336
+ fromLog &&
337
+ !(fromLog.includes('kernel-oom-killer') && !evidence.mainOom)
338
+ ) {
323
339
  return fromLog;
324
340
  }
325
341
 
@@ -328,7 +344,9 @@ function resolveExitReason(input) {
328
344
  }
329
345
 
330
346
  const signalName = signalNameForExitCode(input.exitCode);
331
- return signalName ? `signal (${signalName})` : null;
347
+ return signalName
348
+ ? `signal (${signalName}${signalName === 'SIGKILL' ? '; cause unknown' : ''})`
349
+ : null;
332
350
  }
333
351
 
334
352
  module.exports = {
@@ -9,6 +9,7 @@ const os = require('os');
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { getTimestamp } = require('./isolation');
12
+ const { sanitizeLogToTemp, StreamingSanitizer } = require('./log-sanitizer');
12
13
 
13
14
  /**
14
15
  * Handle command failure - detect repository, upload log, create issue
@@ -55,7 +56,7 @@ function handleFailure(config, cmdName, fullCommand, exitCode, logPath) {
55
56
  console.log('Log upload disabled via START_DISABLE_LOG_UPLOAD');
56
57
  }
57
58
  } else if (isGhUploadLogAvailable()) {
58
- logUrl = uploadLog(logPath);
59
+ logUrl = uploadLog(logPath, { verbose: config.verbose });
59
60
  if (logUrl) {
60
61
  console.log(`Log uploaded: ${logUrl}`);
61
62
  }
@@ -273,9 +274,11 @@ function isGhUploadLogAvailable() {
273
274
  * @param {string} logPath - Path to the log file
274
275
  * @returns {string|null} URL of the uploaded log or null
275
276
  */
276
- function uploadLog(logPath) {
277
+ function uploadLog(logPath, options = {}) {
278
+ let prepared;
277
279
  try {
278
- const result = execFileSync('gh-upload-log', [logPath, '--public'], {
280
+ prepared = sanitizeLogToTemp(logPath, { verbose: options.verbose });
281
+ const result = execFileSync('gh-upload-log', [prepared.path, '--private'], {
279
282
  encoding: 'utf8',
280
283
  stdio: ['pipe', 'pipe', 'pipe'],
281
284
  });
@@ -293,9 +296,13 @@ function uploadLog(logPath) {
293
296
  }
294
297
 
295
298
  return null;
296
- } catch (err) {
297
- console.log(`Warning: Log upload failed - ${err.message}`);
299
+ } catch {
300
+ console.log('Warning: Log preparation or upload failed; upload blocked.');
298
301
  return null;
302
+ } finally {
303
+ if (prepared) {
304
+ prepared.cleanup();
305
+ }
299
306
  }
300
307
  }
301
308
 
@@ -327,6 +334,9 @@ function canCreateIssue(owner, repo) {
327
334
  */
328
335
  function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
329
336
  try {
337
+ fullCommand = new StreamingSanitizer()
338
+ .push(Buffer.from(fullCommand), true)
339
+ .toString();
330
340
  const title = `Command failed with exit code ${exitCode}: ${fullCommand.substring(0, 50)}${fullCommand.length > 50 ? '...' : ''}`;
331
341
 
332
342
  // Get runtime information
@@ -547,7 +547,32 @@ function runInDocker(command, options = {}) {
547
547
  });
548
548
  }
549
549
 
550
+ if (!options.resolvedLimits) {
551
+ try {
552
+ Object.assign(
553
+ options,
554
+ require('./docker-resource-options').resolveResourceOptions(
555
+ options,
556
+ require('./execution-control').runCommand
557
+ )
558
+ );
559
+ } catch (error) {
560
+ return Promise.resolve({
561
+ success: false,
562
+ containerName: null,
563
+ message: error.message,
564
+ });
565
+ }
566
+ }
550
567
  const containerName = options.session || generateSessionName('docker');
568
+ const attribution = {
569
+ ...options,
570
+ containerName,
571
+ sessionId:
572
+ options.sessionId ||
573
+ options.executionId ||
574
+ require('crypto').randomUUID(),
575
+ };
551
576
  const containerExistedBeforeLaunch =
552
577
  readDockerContainerStatus(containerName) !== null;
553
578
  const cleanupPolicy = getDockerContainerCleanupPolicy(options);
@@ -587,7 +612,7 @@ function runInDocker(command, options = {}) {
587
612
  dockerArgs.push('--user', options.user);
588
613
  }
589
614
 
590
- dockerArgs.push(...buildDockerRuntimeArgs(options));
615
+ dockerArgs.push(...buildDockerRuntimeArgs(attribution));
591
616
 
592
617
  const effectiveCommand = options.keepAlive
593
618
  ? `${command}; exec ${shellToUse}`
@@ -597,12 +622,12 @@ function runInDocker(command, options = {}) {
597
622
  : [shellToUse];
598
623
  const mainCmdArgs = isBareShell
599
624
  ? toShellWords(command)
600
- : isShellInvocationWithArgs(command)
625
+ : isShellInvocationWithArgs(effectiveCommand)
601
626
  ? buildShellWithArgsCmdArgs(effectiveCommand)
602
627
  : [...shellArgs, '-c', effectiveCommand];
603
628
  // A recovery command is selected by a marker that `docker cp` drops
604
629
  // into the container before it is started again (issue #176).
605
- const cmdArgs = options.recoveryCommand
630
+ const recoveryArgs = options.recoveryCommand
606
631
  ? buildRecoverySelectorArgs(mainCmdArgs, {
607
632
  shell: shellToUse,
608
633
  shellFlag: shellInteractiveFlag,
@@ -611,6 +636,14 @@ function runInDocker(command, options = {}) {
611
636
  : options.recoveryCommand,
612
637
  })
613
638
  : mainCmdArgs;
639
+ const {
640
+ buildCommandHandoffArgs,
641
+ commandHandoffPath,
642
+ } = require('./docker-command-handoff');
643
+ const cmdArgs = buildCommandHandoffArgs(
644
+ recoveryArgs,
645
+ commandHandoffPath(containerName)
646
+ );
614
647
  dockerArgs.push(options.image, ...cmdArgs);
615
648
 
616
649
  if (DEBUG) {
@@ -665,7 +698,7 @@ function runInDocker(command, options = {}) {
665
698
  cleanupPolicy,
666
699
  options.logPath,
667
700
  options.executionId || null,
668
- { recoverOnKill: Boolean(options.onKillResume) }
701
+ { recoverOnKill: Boolean(options.onKillResume), cpuOptions: options }
669
702
  );
670
703
  }
671
704
 
@@ -700,7 +733,7 @@ function runInDocker(command, options = {}) {
700
733
  if (options.user) {
701
734
  dockerArgs.push('--user', options.user);
702
735
  }
703
- dockerArgs.push(...buildDockerRuntimeArgs(options));
736
+ dockerArgs.push(...buildDockerRuntimeArgs(attribution));
704
737
  if (DEBUG) {
705
738
  console.log(`[DEBUG] shell: ${shellToUse}`);
706
739
  }
@@ -745,9 +778,14 @@ function runInDocker(command, options = {}) {
745
778
  const launchArgs = needsNetworkSetup
746
779
  ? ['start', '-a', '-i', containerName]
747
780
  : dockerArgs;
781
+ const diagnostics =
782
+ require('./attached-diagnostics').startAttachedDiagnostics(
783
+ containerName,
784
+ options
785
+ );
748
786
  const child = spawnAttachedDocker(launchArgs, options.logPath);
749
787
 
750
- child.on('exit', (code) => {
788
+ child.on('exit', async (code) => {
751
789
  const durationMs = Date.now() - startTime;
752
790
  const exitCode = code ?? 1;
753
791
  let message = `Docker container "${containerName}" exited with code ${exitCode}`;
@@ -762,6 +800,7 @@ function runInDocker(command, options = {}) {
762
800
  }
763
801
 
764
802
  const dockerState = readDockerContainerState(containerName);
803
+ const diagnosticFacts = await diagnostics.finish(dockerState);
765
804
  const oomKilled = dockerState ? dockerState.oomKilled : null;
766
805
  const postMortem = (removed) =>
767
806
  recordAttachedDockerPostMortem({
@@ -806,12 +845,14 @@ function runInDocker(command, options = {}) {
806
845
  resolve({
807
846
  success: exitCode === 0,
808
847
  containerName,
848
+ diagnostics: diagnosticFacts,
809
849
  message,
810
850
  exitCode,
811
851
  });
812
852
  });
813
853
 
814
- child.on('error', (err) => {
854
+ child.on('error', async (err) => {
855
+ await diagnostics.finish(null);
815
856
  resolve({
816
857
  success: false,
817
858
  containerName,
@@ -0,0 +1,25 @@
1
+ /** A dead launcher leaves a usable reservation, rather than a permanent gate. */
2
+ const os = require('os');
3
+
4
+ function markLaunch(record) {
5
+ record.options.launchPending = true;
6
+ record.options.launchOwner = { pid: process.pid, hostname: os.hostname() };
7
+ }
8
+
9
+ function hasActiveLaunch(record) {
10
+ if (!record?.options?.launchPending) {
11
+ return false;
12
+ }
13
+ const owner = record.options.launchOwner;
14
+ if (!owner || owner.hostname !== os.hostname()) {
15
+ return true;
16
+ }
17
+ try {
18
+ process.kill(owner.pid, 0);
19
+ return true;
20
+ } catch (error) {
21
+ return error.code !== 'ESRCH';
22
+ }
23
+ }
24
+
25
+ module.exports = { markLaunch, hasActiveLaunch };
@@ -0,0 +1,54 @@
1
+ /** Reserve before launch, and identify/stop a launch whose final save failed. */
2
+ const { getDockerCommand } = require('./docker-cleanup');
3
+
4
+ function reserveLaunch(store, record, previous) {
5
+ require('./launch-owner').markLaunch(record);
6
+ if (typeof store.reserveLaunch === 'function') {
7
+ store.reserveLaunch(record, previous);
8
+ } else {
9
+ store.save(record);
10
+ }
11
+ }
12
+
13
+ function rollbackLaunch(store, previous) {
14
+ try {
15
+ store.save(previous);
16
+ } catch {
17
+ /* The durable reservation still names the session. */
18
+ }
19
+ }
20
+
21
+ function failLaunchedPersistence(store, record, runner, error) {
22
+ const name = record.options.sessionName;
23
+ let stopped;
24
+ try {
25
+ stopped =
26
+ record.options.isolated === 'docker'
27
+ ? runner(getDockerCommand(), ['stop', name])
28
+ : null;
29
+ } catch {
30
+ stopped = null;
31
+ }
32
+ const warning = {
33
+ code: 'LAUNCH_PERSISTENCE_FAILED',
34
+ uuid: record.uuid,
35
+ containerName: name,
36
+ running: !stopped?.success,
37
+ error: error.message,
38
+ };
39
+ if (stopped?.success) {
40
+ record.status = 'executed';
41
+ record.exitCode = -1;
42
+ record.endTime = new Date().toISOString();
43
+ record.endTimeSource = 'observed-at';
44
+ record.options.launchPending = false;
45
+ try {
46
+ store.save(record);
47
+ } catch {
48
+ /* Completion watcher remains attached. */
49
+ }
50
+ }
51
+ return JSON.stringify(warning);
52
+ }
53
+
54
+ module.exports = { reserveLaunch, rollbackLaunch, failLaunchedPersistence };