start-command 0.32.1 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +32 -137
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/detached-finalize.js +281 -0
  9. package/src/lib/docker-cleanup.js +240 -39
  10. package/src/lib/docker-post-mortem.js +289 -0
  11. package/src/lib/execution-attach.js +245 -0
  12. package/src/lib/execution-resume-all.js +211 -0
  13. package/src/lib/execution-resume.js +455 -0
  14. package/src/lib/execution-store.js +93 -6
  15. package/src/lib/exit-reason.js +264 -0
  16. package/src/lib/failure-handler.js +43 -41
  17. package/src/lib/isolation-log-utils.js +66 -1
  18. package/src/lib/isolation.js +28 -15
  19. package/src/lib/query-commands.js +202 -0
  20. package/src/lib/screen-isolation.js +2 -1
  21. package/src/lib/session-probe.js +121 -0
  22. package/src/lib/shell-utils.js +164 -10
  23. package/src/lib/status-formatter.js +233 -69
  24. package/src/lib/usage.js +13 -1
  25. package/src/lib/user-manager.js +18 -1
  26. package/test/args-parser-attach-resume.js +159 -0
  27. package/test/args-parser-control.js +3 -3
  28. package/test/args-parser.js +1 -1
  29. package/test/check-file-size.js +91 -0
  30. package/test/ci-workflow-invariants.js +191 -0
  31. package/test/echo-integration.js +20 -13
  32. package/test/execution-attach.js +221 -0
  33. package/test/execution-resume.js +433 -0
  34. package/test/execution-store.js +57 -2
  35. package/test/exit-reason.js +209 -0
  36. package/test/failure-handler.js +107 -0
  37. package/test/load-command-stream.mjs +83 -0
  38. package/test/regression-162.js +320 -0
  39. package/test/regression-164.js +308 -0
  40. package/test/regression-165.js +310 -0
  41. package/test/regression-170.js +321 -0
  42. package/test/regression-171.js +562 -0
  43. package/test/regression-84.js +8 -3
  44. package/test/release-name.mjs +57 -0
  45. package/test/session-probe.js +150 -0
  46. package/test/user-manager.js +23 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Exit reason hints for finished executions.
3
+ *
4
+ * A numeric exit code alone is frequently misleading: a Bun/Node process that
5
+ * exhausts its heap aborts itself, so the container reports `exitCode 139` with
6
+ * `OOMKilled false` even though the run died of memory exhaustion (issue #162,
7
+ * related to #144, #148 and #151). `start` already reads the tail of the log to
8
+ * find the terminal footer, so the same tail is scanned for well-known fatal
9
+ * markers and the finding is surfaced as an extra `exitReason` field.
10
+ *
11
+ * The same scan answers a narrower question for consumers that key off
12
+ * `oomKilled`: was this run killed by memory exhaustion at all? That answer is
13
+ * surfaced as `memoryExhausted` plus `memoryExhaustedReason`, the log line that
14
+ * carried the evidence (issue #165).
15
+ *
16
+ * Both fields are hints, never verdicts: they never change `status`,
17
+ * `exitCode` or `oomKilled`.
18
+ */
19
+
20
+ /**
21
+ * Reasons carrying this prefix report memory exhaustion, whatever the mechanism
22
+ * (runtime self-abort, kernel OOM killer, failed allocation).
23
+ */
24
+ const MEMORY_EXHAUSTION_PREFIX = 'memory-exhaustion';
25
+
26
+ /**
27
+ * Fatal markers, most specific first. The first matching entry wins.
28
+ * @type {{reason: string, pattern: RegExp}[]}
29
+ */
30
+ const EXIT_REASON_MARKERS = [
31
+ {
32
+ reason: 'memory-exhaustion (v8-heap-limit)',
33
+ pattern:
34
+ /FATAL ERROR:[^\r\n]*(?:Reached heap limit|JavaScript heap out of memory)/i,
35
+ },
36
+ {
37
+ reason: 'memory-exhaustion (v8-heap-limit)',
38
+ pattern: /JavaScript heap out of memory/i,
39
+ },
40
+ {
41
+ reason: 'memory-exhaustion (kernel-oom-killer)',
42
+ pattern:
43
+ /Out of memory: Kill(?:ed)? process|oom-kill(?:er)?[: ]|Killed process \d+/i,
44
+ },
45
+ {
46
+ reason: 'memory-exhaustion (go-runtime)',
47
+ pattern: /fatal error: runtime: out of memory/i,
48
+ },
49
+ {
50
+ reason: 'memory-exhaustion (allocation-failure)',
51
+ pattern:
52
+ /std::bad_alloc|Cannot allocate memory|memory allocation of \d+ bytes failed|Allocation failed - process out of memory|Array buffer allocation failed/i,
53
+ },
54
+ ];
55
+
56
+ /**
57
+ * Exit codes above 128 encode the signal that killed the process.
58
+ * Only the signals that actually show up in command logs are named.
59
+ * @type {Object<number, string>}
60
+ */
61
+ /** Rendered when an exit code is unavailable or not numeric. */
62
+ const UNKNOWN_EXIT_CODE = 'unknown';
63
+
64
+ const SIGNAL_NAMES = {
65
+ 1: 'SIGHUP',
66
+ 2: 'SIGINT',
67
+ 3: 'SIGQUIT',
68
+ 4: 'SIGILL',
69
+ 6: 'SIGABRT',
70
+ 8: 'SIGFPE',
71
+ 9: 'SIGKILL',
72
+ 11: 'SIGSEGV',
73
+ 13: 'SIGPIPE',
74
+ 15: 'SIGTERM',
75
+ };
76
+
77
+ /**
78
+ * Longest `memoryExhaustedReason` reported. A marker line is a single line of
79
+ * runtime output, but a log can contain arbitrarily long lines, and the reason
80
+ * travels inside status records that stay readable only when bounded.
81
+ */
82
+ const MAX_MARKER_LINE_LENGTH = 300;
83
+
84
+ /**
85
+ * Extract the whole log line containing `index`, trimmed and length-bounded.
86
+ * @param {string} text - Log text
87
+ * @param {number} index - Offset of the match inside `text`
88
+ * @returns {string} The line the match sits on
89
+ */
90
+ function extractLineAt(text, index) {
91
+ const start = text.lastIndexOf('\n', index) + 1;
92
+ const end = text.indexOf('\n', index);
93
+ const line = text.slice(start, end === -1 ? undefined : end).trim();
94
+ return line.length > MAX_MARKER_LINE_LENGTH
95
+ ? `${line.slice(0, MAX_MARKER_LINE_LENGTH)}...`
96
+ : line;
97
+ }
98
+
99
+ /**
100
+ * Find the first known fatal marker in log text.
101
+ * @param {string|null|undefined} text - Log text (usually the log tail)
102
+ * @param {(marker: {reason: string, pattern: RegExp}) => boolean} [accept] - Marker filter
103
+ * @returns {{reason: string, line: string}|null} Marker and the line carrying it
104
+ */
105
+ function findExitReasonMarker(text, accept = () => true) {
106
+ if (!text) {
107
+ return null;
108
+ }
109
+ for (const marker of EXIT_REASON_MARKERS) {
110
+ if (!accept(marker)) {
111
+ continue;
112
+ }
113
+ const match = marker.pattern.exec(text);
114
+ if (match) {
115
+ return { reason: marker.reason, line: extractLineAt(text, match.index) };
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /**
122
+ * Scan log text for a known fatal marker.
123
+ * @param {string|null|undefined} text - Log text (usually the log tail)
124
+ * @returns {string|null} Detected reason, or null when nothing matched
125
+ */
126
+ function detectExitReason(text) {
127
+ const marker = findExitReasonMarker(text);
128
+ return marker ? marker.reason : null;
129
+ }
130
+
131
+ /**
132
+ * Scan log text for a marker that specifically reports memory exhaustion.
133
+ * @param {string|null|undefined} text - Log text (usually the log tail)
134
+ * @returns {{reason: string, line: string}|null} Marker and the line carrying it
135
+ */
136
+ function detectMemoryMarker(text) {
137
+ return findExitReasonMarker(text, (marker) =>
138
+ marker.reason.startsWith(MEMORY_EXHAUSTION_PREFIX)
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Resolve the memory-exhaustion observation for a finished execution (#165).
144
+ *
145
+ * A runtime that aborts on its own heap limit is invisible to every container
146
+ * signal: it dies below the container limit, so `State.OOMKilled` stays `false`
147
+ * and the cgroup `oom_kill` counter stays `0`. The only evidence is what the
148
+ * runtime printed on its way out, which sits in the very log tail the footer
149
+ * scan already reads.
150
+ *
151
+ * Like `oomKilled` (#151) this is an *observation*, never a verdict: it is only
152
+ * derived for a run that already ended abnormally, so a log that merely quotes
153
+ * a fatal marker (a test fixture, an `rg` dump) cannot turn a clean run into a
154
+ * reported memory failure.
155
+ *
156
+ * @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean}|null} input
157
+ * @returns {{memoryExhausted: true, memoryExhaustedReason: string}|null}
158
+ */
159
+ function resolveMemoryExhaustion(input) {
160
+ if (!input) {
161
+ return null;
162
+ }
163
+ const { exitCode } = input;
164
+ if (
165
+ typeof exitCode !== 'number' ||
166
+ !Number.isFinite(exitCode) ||
167
+ exitCode === 0
168
+ ) {
169
+ return null;
170
+ }
171
+
172
+ const marker = detectMemoryMarker(input.logTail);
173
+ if (marker) {
174
+ return { memoryExhausted: true, memoryExhaustedReason: marker.line };
175
+ }
176
+ if (input.oomKilled === true) {
177
+ return {
178
+ memoryExhausted: true,
179
+ memoryExhaustedReason: 'Docker reported State.OOMKilled=true',
180
+ };
181
+ }
182
+ return null;
183
+ }
184
+
185
+ /**
186
+ * Map a shell exit code to the signal name it encodes.
187
+ * @param {number|null|undefined} exitCode - Terminal exit code
188
+ * @returns {string|null} Signal name, or null when the code is not a signal
189
+ */
190
+ function signalNameForExitCode(exitCode) {
191
+ if (typeof exitCode !== 'number' || !Number.isFinite(exitCode)) {
192
+ return null;
193
+ }
194
+ if (exitCode <= 128 || exitCode > 128 + 64) {
195
+ return null;
196
+ }
197
+ return SIGNAL_NAMES[exitCode - 128] || null;
198
+ }
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
+
225
+ /**
226
+ * Resolve the best available hint for why an execution ended.
227
+ *
228
+ * Precedence: the log marker (evidence written by the command itself), then the
229
+ * cgroup OOM observation, then the signal encoded in the exit code.
230
+ *
231
+ * @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean}|null} input
232
+ * @returns {string|null} Exit reason hint, or null when nothing is known
233
+ */
234
+ function resolveExitReason(input) {
235
+ if (!input) {
236
+ return null;
237
+ }
238
+
239
+ const fromLog = detectExitReason(input.logTail);
240
+ if (fromLog) {
241
+ return fromLog;
242
+ }
243
+
244
+ if (input.oomKilled === true) {
245
+ return 'memory-exhaustion (cgroup-oom-killer)';
246
+ }
247
+
248
+ const signalName = signalNameForExitCode(input.exitCode);
249
+ return signalName ? `signal (${signalName})` : null;
250
+ }
251
+
252
+ module.exports = {
253
+ EXIT_REASON_MARKERS,
254
+ UNKNOWN_EXIT_CODE,
255
+ MEMORY_EXHAUSTION_PREFIX,
256
+ SIGNAL_NAMES,
257
+ detectExitReason,
258
+ detectMemoryMarker,
259
+ findExitReasonMarker,
260
+ resolveMemoryExhaustion,
261
+ describeExitCode,
262
+ resolveExitReason,
263
+ signalNameForExitCode,
264
+ };
@@ -4,7 +4,7 @@
4
4
  * Handles command failures - detects repository, uploads logs, creates issues
5
5
  */
6
6
 
7
- const { execSync } = require('child_process');
7
+ const { execFileSync } = require('child_process');
8
8
  const os = require('os');
9
9
  const fs = require('fs');
10
10
  const path = require('path');
@@ -97,7 +97,7 @@ function detectRepository(cmdName) {
97
97
  let cmdPath;
98
98
 
99
99
  try {
100
- cmdPath = execSync(`${whichCmd} ${cmdName}`, {
100
+ cmdPath = execFileSync(whichCmd, [cmdName], {
101
101
  encoding: 'utf8',
102
102
  stdio: ['pipe', 'pipe', 'pipe'],
103
103
  }).trim();
@@ -117,7 +117,7 @@ function detectRepository(cmdName) {
117
117
  // Check if it's in npm global modules
118
118
  let npmGlobalPath;
119
119
  try {
120
- npmGlobalPath = execSync('npm root -g', {
120
+ npmGlobalPath = execFileSync('npm', ['root', '-g'], {
121
121
  encoding: 'utf8',
122
122
  stdio: ['pipe', 'pipe', 'pipe'],
123
123
  }).trim();
@@ -183,42 +183,28 @@ function detectRepository(cmdName) {
183
183
  packageName = cmdName;
184
184
  }
185
185
 
186
- // Try to get repository URL from npm
187
- try {
188
- const npmInfo = execSync(
189
- `npm view ${packageName} repository.url 2>/dev/null`,
190
- {
186
+ // Try the repository URL, then the bugs URL, from the npm registry.
187
+ // execFileSync, not execSync: packageName comes from a path or from the
188
+ // failing command's own name, and a shell would run anything it carries
189
+ // (CodeQL js/shell-command-injection-from-environment).
190
+ for (const field of ['repository.url', 'bugs.url']) {
191
+ let url;
192
+ try {
193
+ url = execFileSync('npm', ['view', packageName, field], {
191
194
  encoding: 'utf8',
192
- stdio: ['pipe', 'pipe', 'pipe'],
193
- }
194
- ).trim();
195
-
196
- if (npmInfo) {
197
- // Parse git URL to extract owner and repo
198
- const parsed = parseGitUrl(npmInfo);
199
- if (parsed) {
200
- return parsed;
201
- }
195
+ stdio: ['pipe', 'pipe', 'ignore'],
196
+ }).trim();
197
+ } catch {
198
+ // npm view failed: package might not exist or have no such field.
199
+ continue;
202
200
  }
203
- } catch {
204
- // npm view failed, package might not exist or have no repository
205
- }
206
201
 
207
- // Try to get homepage or bugs URL as fallback
208
- try {
209
- const bugsUrl = execSync(`npm view ${packageName} bugs.url 2>/dev/null`, {
210
- encoding: 'utf8',
211
- stdio: ['pipe', 'pipe', 'pipe'],
212
- }).trim();
213
-
214
- if (bugsUrl && bugsUrl.includes('github.com')) {
215
- const parsed = parseGitUrl(bugsUrl);
216
- if (parsed) {
217
- return parsed;
218
- }
202
+ // parseGitUrl anchors on the github.com host itself, so no separate
203
+ // (and substring-only) host check is needed here.
204
+ const parsed = parseGitUrl(url);
205
+ if (parsed) {
206
+ return parsed;
219
207
  }
220
- } catch {
221
- // Fallback also failed
222
208
  }
223
209
 
224
210
  return null;
@@ -258,7 +244,7 @@ function parseGitUrl(url) {
258
244
  */
259
245
  function isGhAuthenticated() {
260
246
  try {
261
- execSync('gh auth status', { stdio: ['pipe', 'pipe', 'pipe'] });
247
+ execFileSync('gh', ['auth', 'status'], { stdio: ['pipe', 'pipe', 'pipe'] });
262
248
  return true;
263
249
  } catch {
264
250
  return false;
@@ -273,7 +259,9 @@ function isGhUploadLogAvailable() {
273
259
  const isWindows = process.platform === 'win32';
274
260
  try {
275
261
  const whichCmd = isWindows ? 'where' : 'which';
276
- execSync(`${whichCmd} gh-upload-log`, { stdio: ['pipe', 'pipe', 'pipe'] });
262
+ execFileSync(whichCmd, ['gh-upload-log'], {
263
+ stdio: ['pipe', 'pipe', 'pipe'],
264
+ });
277
265
  return true;
278
266
  } catch {
279
267
  return false;
@@ -287,7 +275,7 @@ function isGhUploadLogAvailable() {
287
275
  */
288
276
  function uploadLog(logPath) {
289
277
  try {
290
- const result = execSync(`gh-upload-log "${logPath}" --public`, {
278
+ const result = execFileSync('gh-upload-log', [logPath, '--public'], {
291
279
  encoding: 'utf8',
292
280
  stdio: ['pipe', 'pipe', 'pipe'],
293
281
  });
@@ -320,7 +308,7 @@ function uploadLog(logPath) {
320
308
  function canCreateIssue(owner, repo) {
321
309
  try {
322
310
  // Check if the repository exists and we have access
323
- execSync(`gh repo view ${owner}/${repo} --json name`, {
311
+ execFileSync('gh', ['repo', 'view', `${owner}/${repo}`, '--json', 'name'], {
324
312
  stdio: ['pipe', 'pipe', 'pipe'],
325
313
  });
326
314
  return true;
@@ -364,8 +352,22 @@ function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
364
352
  body += `---\n`;
365
353
  body += `*This issue was automatically created by [start-command](https://github.com/link-foundation/start)*\n`;
366
354
 
367
- const result = execSync(
368
- `gh issue create --repo ${repoInfo.owner}/${repoInfo.repo} --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`,
355
+ // execFileSync passes the title and body as argv entries, so no quoting is
356
+ // involved: the failing command they quote reached the shell verbatim
357
+ // before, and its newlines survived only as the two characters "\n"
358
+ // (CodeQL js/incomplete-sanitization, issue #168).
359
+ const result = execFileSync(
360
+ 'gh',
361
+ [
362
+ 'issue',
363
+ 'create',
364
+ '--repo',
365
+ `${repoInfo.owner}/${repoInfo.repo}`,
366
+ '--title',
367
+ title,
368
+ '--body',
369
+ body,
370
+ ],
369
371
  {
370
372
  encoding: 'utf8',
371
373
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -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
 
@@ -204,7 +208,68 @@ function runAsIsolatedUser(cmd, username) {
204
208
  });
205
209
  }
206
210
 
211
+ /**
212
+ * Number of trailing bytes scanned for the terminal log footer. The footer is
213
+ * always appended at the very end of the log, so there is no reason to read
214
+ * (potentially megabytes of) command output on every `--status` call.
215
+ */
216
+ const LOG_TAIL_BYTES = 16 * 1024;
217
+
218
+ /**
219
+ * Number of trailing bytes scanned for a fatal marker (issue #165).
220
+ *
221
+ * Wider than the footer window because a dying runtime keeps printing after the
222
+ * marker: V8 emits a full native stack trace between `FATAL ERROR: Reached heap
223
+ * limit ...` and the end of the log, which can push the marker several tens of
224
+ * kilobytes away from EOF.
225
+ */
226
+ const FATAL_MARKER_TAIL_BYTES = 64 * 1024;
227
+
228
+ /**
229
+ * Read the last `bytes` bytes of a file as UTF-8 text.
230
+ *
231
+ * A partial first line is dropped: the slice can start in the middle of a line,
232
+ * and that fragment must not be treated as the beginning of a line by the
233
+ * anchored footer pattern.
234
+ *
235
+ * @param {string} logPath - Path to the log file
236
+ * @param {number} [bytes] - Maximum number of trailing bytes to read
237
+ * @returns {string|null} Tail content, or null when the file cannot be read
238
+ */
239
+ function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
240
+ let fd;
241
+ try {
242
+ fd = fs.openSync(logPath, 'r');
243
+ const size = fs.fstatSync(fd).size;
244
+ const length = Math.min(size, bytes);
245
+ const buffer = Buffer.alloc(length);
246
+ fs.readSync(fd, buffer, 0, length, size - length);
247
+ const tail = buffer.toString('utf8');
248
+ if (size <= bytes) {
249
+ return tail;
250
+ }
251
+ const firstNewline = tail.indexOf('\n');
252
+ return firstNewline === -1 ? '' : tail.slice(firstNewline + 1);
253
+ } catch {
254
+ return null;
255
+ } finally {
256
+ if (fd !== undefined) {
257
+ try {
258
+ fs.closeSync(fd);
259
+ } catch {
260
+ // ignore
261
+ }
262
+ }
263
+ }
264
+ }
265
+
207
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,
270
+ LOG_TAIL_BYTES,
271
+ FATAL_MARKER_TAIL_BYTES,
272
+ readLogTail,
208
273
  getTempRoot,
209
274
  ensureDirectory,
210
275
  ensureParentDirectory,
@@ -27,12 +27,14 @@ const {
27
27
  wrapCommandWithLogFooter,
28
28
  } = require('./isolation-log-utils');
29
29
  const {
30
+ buildAttachedDockerKeptMessage,
31
+ readDockerContainerState,
32
+ recordAttachedDockerPostMortem,
30
33
  DOCKER_CONTAINER_CLEANUP_POLICY,
31
34
  getDockerContainerCleanupPolicy,
32
35
  shouldCleanupDockerContainer,
33
36
  getDockerContainerCleanupInstructions,
34
37
  appendDockerContainerCleanupPolicyMessage,
35
- readDockerContainerOomKilled,
36
38
  readDockerContainerStatus,
37
39
  removeDockerContainer,
38
40
  startDetachedDockerCompletionWatcher,
@@ -168,6 +170,7 @@ const {
168
170
  isShellInvocationWithArgs,
169
171
  buildShellWithArgsCmdArgs,
170
172
  buildDisplayCommand,
173
+ toShellWords,
171
174
  } = require('./shell-utils');
172
175
 
173
176
  /** Returns true if the current process has a TTY attached. */
@@ -452,7 +455,7 @@ function runInSsh(command, options = {}) {
452
455
  } else {
453
456
  const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
454
457
  const sshArgs = isInteractiveShellCommand(command)
455
- ? [sshTarget, ...command.trim().split(/\s+/)]
458
+ ? [sshTarget, ...toShellWords(command)]
456
459
  : useExplicitShell
457
460
  ? [sshTarget, useExplicitShell, ...extraFlags, '-c', command]
458
461
  : [sshTarget, command];
@@ -677,7 +680,7 @@ function runInDocker(command, options = {}) {
677
680
  ? [shellToUse, shellInteractiveFlag]
678
681
  : [shellToUse];
679
682
  const cmdArgs = isBareShell
680
- ? command.trim().split(/\s+/)
683
+ ? toShellWords(command)
681
684
  : isShellInvocationWithArgs(command)
682
685
  ? buildShellWithArgsCmdArgs(effectiveCommand)
683
686
  : [...shellArgs, '-c', effectiveCommand];
@@ -732,7 +735,8 @@ function runInDocker(command, options = {}) {
732
735
  startDetachedDockerCompletionWatcher(
733
736
  containerName,
734
737
  cleanupPolicy,
735
- options.logPath
738
+ options.logPath,
739
+ options.executionId || null
736
740
  );
737
741
 
738
742
  let message = `Command started in detached docker container: ${containerName}`;
@@ -747,7 +751,7 @@ function runInDocker(command, options = {}) {
747
751
  containerName,
748
752
  cleanupPolicy
749
753
  );
750
- message += `\nAttach with: docker attach ${containerName}`;
754
+ message += `\nAttach with: $ --attach ${containerName} (or docker attach ${containerName})`;
751
755
  message += `\nView logs: docker logs ${containerName}`;
752
756
  if (options.logPath) {
753
757
  message += `\nLive log: ${options.logPath}`;
@@ -777,7 +781,7 @@ function runInDocker(command, options = {}) {
777
781
  // Shell with -c: pass directly as argv (avoids double-wrapping and quote-stripping, issue #91).
778
782
  let attachedCmdArgs;
779
783
  if (isBareShell) {
780
- const parts = command.trim().split(/\s+/);
784
+ const parts = toShellWords(command);
781
785
  const bareFlag = getShellInteractiveFlag(parts[0]);
782
786
  attachedCmdArgs =
783
787
  bareFlag && !parts.includes(bareFlag)
@@ -819,7 +823,7 @@ function runInDocker(command, options = {}) {
819
823
  let message = `Docker container "${containerName}" exited with code ${exitCode}`;
820
824
  // Bare shell exited non-zero quickly → startup file error; suggest --norc (issue #84).
821
825
  if (isBareShell && exitCode !== 0 && durationMs < 3000) {
822
- const shell0 = command.trim().split(/\s+/)[0];
826
+ const shell0 = toShellWords(command)[0];
823
827
  // prettier-ignore
824
828
  const norc = path.basename(shell0) === 'zsh' ? '--no-rcs' : '--norc';
825
829
  const hint = `Hint: The shell exited immediately — its startup file (.bashrc/.zshrc) may have errors.\nTry skipping startup files: ${shell0} ${norc}`;
@@ -827,7 +831,15 @@ function runInDocker(command, options = {}) {
827
831
  message += `\n${hint}`;
828
832
  }
829
833
 
830
- 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
+ });
831
843
  const launchFailed =
832
844
  !containerExistedBeforeLaunch &&
833
845
  readDockerContainerStatus(containerName) === 'created';
@@ -842,22 +854,23 @@ function runInDocker(command, options = {}) {
842
854
  shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
843
855
  ) {
844
856
  if (removeDockerContainer(containerName, options.logPath)) {
845
- message += `\nContainer removed after completion.`;
857
+ message += `\nContainer removed after completion.${postMortem(true)}`;
846
858
  } else {
847
859
  message += `\nWarning: failed to remove container automatically.`;
848
860
  message += `\nRemove when done: docker rm -f ${containerName}`;
849
861
  }
850
862
  } else if (cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
851
- message += `\n${getDockerContainerCleanupInstructions(containerName)}`;
863
+ message += `\n${getDockerContainerCleanupInstructions(containerName)}${postMortem(false)}`;
852
864
  } else if (
853
865
  cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL ||
854
866
  cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT
855
867
  ) {
856
- message +=
857
- oomKilled === true
858
- ? `\nContainer kept because Docker reports it was OOM-killed.`
859
- : `\nContainer kept because the command failed.`;
860
- message += `\nRemove when done: docker rm -f ${containerName}`;
868
+ message += `${buildAttachedDockerKeptMessage({
869
+ containerName,
870
+ exitCode,
871
+ oomKilled,
872
+ logPath: options.logPath,
873
+ })}${postMortem(false)}`;
861
874
  }
862
875
 
863
876
  resolve({