start-command 0.29.0 → 0.29.1

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,13 @@
1
1
  # start-command
2
2
 
3
+ ## 0.29.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix detached docker `--status`/`--list` reporting a terminal status (`executed`) with the `-1` sentinel while the container is still running (or not visible yet on a slow Docker-in-Docker host). `isDetachedSessionAlive()` now treats a failed `docker inspect` as "unknown" (returns `null`) instead of "stopped", so a session whose container has not appeared yet stays `executing` rather than being marked finished. When a container has genuinely stopped, `enrichDetachedStatus()` resolves the real exit code from the `Exit Code:` log footer and then `docker inspect .State.ExitCode`, only falling back to `-1` when no real code can be obtained.
8
+
9
+ Fix `--status` for detached executions resurrecting a completed (killed) record. `enrichDetachedStatus()` no longer flips an already-`executed` record back to `executing` (and nulls its exit code) just because `screen -ls`/`tmux`/`docker` still lists a same-named session — a lingering shell can outlive a SIGKILLed command (e.g. OOM, exit 137). The recorded exit code and the `Exit Code:` log footer that `start` itself writes are now treated as authoritative; the record only flips to `executing` when there is no recorded exit code and no terminal footer in the log.
10
+
3
11
  ## 0.29.0
4
12
 
5
13
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
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": {
@@ -7,7 +7,7 @@
7
7
  * - Text: Human-readable text format
8
8
  */
9
9
 
10
- const { execSync } = require('child_process');
10
+ const { execSync, spawnSync } = require('child_process');
11
11
  const fs = require('fs');
12
12
  const {
13
13
  escapeForLinksNotation,
@@ -15,6 +15,58 @@ const {
15
15
  } = require('./output-blocks');
16
16
  const { collectProcessIds } = require('./execution-control');
17
17
 
18
+ /**
19
+ * Inspect the live state of a detached docker container by name.
20
+ *
21
+ * Distinguishes three outcomes that matter for status reporting:
22
+ * - the container exists and is running,
23
+ * - the container exists but has stopped (with a real exit code), and
24
+ * - the container cannot be inspected at all.
25
+ *
26
+ * The last case is crucial on slow Docker-in-Docker hosts (issue #136): right
27
+ * after `docker run -d` returns, `docker inspect <name>` can transiently fail
28
+ * because the container is not visible yet. A failed inspect must NOT be read
29
+ * as "stopped"; it means "unknown", so callers can keep the session running
30
+ * instead of fabricating a terminal `-1` result.
31
+ *
32
+ * @param {string} sessionName - Container name
33
+ * @returns {{running: boolean, exitCode: number|null}|null} State, or null when
34
+ * the container cannot be inspected (not found yet, removed, or docker error)
35
+ */
36
+ function inspectDockerState(sessionName) {
37
+ const result = spawnSync(
38
+ 'docker',
39
+ ['inspect', '-f', '{{.State.Running}} {{.State.ExitCode}}', sessionName],
40
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
41
+ );
42
+ if (result.error || result.status !== 0 || !result.stdout) {
43
+ return null;
44
+ }
45
+ const [runningRaw, exitRaw] = result.stdout.trim().split(/\s+/);
46
+ const exitCode = Number.parseInt(exitRaw, 10);
47
+ return {
48
+ running: runningRaw === 'true',
49
+ exitCode: Number.isFinite(exitCode) ? exitCode : null,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Best-effort terminal exit code reported by the isolation backend itself
55
+ * (currently docker via `docker inspect .State.ExitCode`). Returns null when
56
+ * the backend cannot provide a real code, so callers never surface the `-1`
57
+ * sentinel for a session whose real exit code is simply not available yet.
58
+ * @param {Object} record - Execution record
59
+ * @returns {number|null}
60
+ */
61
+ function readBackendExitCode(record) {
62
+ const opts = record.options || {};
63
+ if (opts.isolated !== 'docker' || !opts.sessionName) {
64
+ return null;
65
+ }
66
+ const state = inspectDockerState(opts.sessionName);
67
+ return state && !state.running ? state.exitCode : null;
68
+ }
69
+
18
70
  /**
19
71
  * Check if a detached isolation session is still running
20
72
  * @param {Object} record - Execution record
@@ -46,11 +98,11 @@ function isDetachedSessionAlive(record) {
46
98
  return true;
47
99
  }
48
100
  case 'docker': {
49
- const output = execSync(
50
- `docker inspect -f "{{.State.Running}}" ${sessionName}`,
51
- { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
52
- );
53
- return output.trim() === 'true';
101
+ // A failed inspect means the container is not visible yet (still being
102
+ // created on a slow DinD host) or already removed — not "stopped".
103
+ // Return null (unknown) so the session is not falsely marked finished.
104
+ const state = inspectDockerState(sessionName);
105
+ return state === null ? null : state.running;
54
106
  }
55
107
  case 'ssh': {
56
108
  // For SSH, check if the PID is still running on remote would require
@@ -100,24 +152,62 @@ function readExitCodeFromLog(logPath) {
100
152
  */
101
153
  function enrichDetachedStatus(record) {
102
154
  const alive = isDetachedSessionAlive(record);
155
+ const footerExit = readExitCodeFromLog(record.logPath);
156
+
157
+ // Create a shallow copy to avoid mutating the original
158
+ const cloneRecord = () => {
159
+ const enriched = Object.create(Object.getPrototypeOf(record));
160
+ Object.assign(enriched, record);
161
+ return enriched;
162
+ };
163
+
103
164
  if (alive === null) {
165
+ // Liveness is unknown: the backend could not be probed (e.g. a detached
166
+ // docker container that is not visible yet on a slow Docker-in-Docker host,
167
+ // or one that has already been removed). Honor a terminal `Exit Code:`
168
+ // footer if the command wrote one; otherwise leave the record untouched
169
+ // (still executing) rather than fabricating a `-1` terminal result that
170
+ // orchestrators misread as a finished/failed run (issue #136).
171
+ const isDetached =
172
+ record.options && record.options.isolationMode === 'detached';
173
+ if (isDetached && record.status === 'executing' && footerExit !== null) {
174
+ const enriched = cloneRecord();
175
+ enriched.status = 'executed';
176
+ enriched.exitCode = footerExit;
177
+ if (!enriched.endTime) {
178
+ enriched.endTime = new Date().toISOString();
179
+ }
180
+ return enriched;
181
+ }
104
182
  return record;
105
183
  }
106
184
 
107
- // Create a shallow copy to avoid mutating the original
108
- const enriched = Object.create(Object.getPrototypeOf(record));
109
- Object.assign(enriched, record);
185
+ const enriched = cloneRecord();
110
186
 
111
187
  if (alive && enriched.status === 'executed') {
112
- // Session still running but record says executed - correct it
113
- enriched.status = 'executing';
114
- enriched.exitCode = null;
115
- enriched.endTime = null;
188
+ // A live `screen -ls` (or `tmux`/`docker`) session does NOT mean the command
189
+ // is still running: a lingering shell can outlive a killed command (e.g. the
190
+ // OOM killer sends SIGKILL, exit 137, but the login shell stays up for a
191
+ // window after `start` already wrote the terminal footer). The footer/recorded
192
+ // exit code is authoritative. Only flip back to 'executing' when there is NO
193
+ // recorded terminal exit code AND no `Exit Code:` footer in the log.
194
+ const hasRecordedExit =
195
+ enriched.exitCode !== null && enriched.exitCode !== undefined;
196
+ if (!hasRecordedExit && footerExit === null) {
197
+ // Session still running and no terminal record - correct it
198
+ enriched.status = 'executing';
199
+ enriched.exitCode = null;
200
+ enriched.endTime = null;
201
+ }
202
+ // Otherwise keep the recorded/footer exit code - the command has finished.
116
203
  } else if (!alive && enriched.status === 'executing') {
117
- // Session ended but record says executing - correct it
204
+ // Session ended but record says executing - correct it. Resolve a real exit
205
+ // code: prefer the log footer, then the backend's own record (e.g.
206
+ // `docker inspect .State.ExitCode`), and only fall back to the `-1` sentinel
207
+ // as a last resort when no real code can be obtained (issue #136).
118
208
  enriched.status = 'executed';
119
209
  if (enriched.exitCode === null || enriched.exitCode === undefined) {
120
- enriched.exitCode = readExitCodeFromLog(enriched.logPath) ?? -1;
210
+ enriched.exitCode = footerExit ?? readBackendExitCode(enriched) ?? -1;
121
211
  }
122
212
  if (!enriched.endTime) {
123
213
  enriched.endTime = new Date().toISOString();
@@ -306,6 +306,249 @@ describe('Issue #101: Detached status enrichment', () => {
306
306
  expect(enriched.endTime).not.toBeNull();
307
307
  }
308
308
  });
309
+
310
+ // Issue #134: a lingering screen session must NOT resurrect a completed
311
+ // (killed, exit 137) record back to 'executing' / null exit code.
312
+ describe('Issue #134: completed record with a lingering live session', () => {
313
+ const screenAvailable = (() => {
314
+ const probe = spawnSync('screen', ['-v'], { encoding: 'utf8' });
315
+ return probe.status === 0 || /Screen version/.test(probe.stdout || '');
316
+ })();
317
+
318
+ let sessionName;
319
+ let logPath;
320
+
321
+ beforeEach(() => {
322
+ if (!screenAvailable) {
323
+ return;
324
+ }
325
+ sessionName = `enrich-134-${process.pid}-${Date.now()}`;
326
+ logPath = path.join(TEST_APP_FOLDER, `${sessionName}.log`);
327
+ if (!fs.existsSync(TEST_APP_FOLDER)) {
328
+ fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
329
+ }
330
+ // Footer exactly as `start` writes it for a SIGKILLed command.
331
+ fs.writeFileSync(
332
+ logPath,
333
+ `Killed\n\n${'='.repeat(50)}\nFinished: 2026-06-14 19:10:49.822\nExit Code: 137\n`
334
+ );
335
+ // A shell that outlives the (already-finished) command.
336
+ spawnSync('screen', ['-dmS', sessionName, 'sh', '-c', 'sleep 30'], {
337
+ encoding: 'utf8',
338
+ });
339
+ });
340
+
341
+ afterEach(() => {
342
+ if (!screenAvailable) {
343
+ return;
344
+ }
345
+ spawnSync('screen', ['-S', sessionName, '-X', 'quit'], {
346
+ stdio: 'ignore',
347
+ });
348
+ if (logPath && fs.existsSync(logPath)) {
349
+ fs.unlinkSync(logPath);
350
+ }
351
+ });
352
+
353
+ it('keeps the recorded exit code when the session is still listed', () => {
354
+ if (!screenAvailable) {
355
+ return;
356
+ }
357
+ const record = new ExecutionRecord({
358
+ command: 'sleep 60',
359
+ logPath,
360
+ options: {
361
+ sessionName,
362
+ isolated: 'screen',
363
+ isolationMode: 'detached',
364
+ },
365
+ });
366
+ record.complete(137);
367
+
368
+ // Sanity: the session must actually be alive for this test to be meaningful.
369
+ expect(isDetachedSessionAlive(record)).toBe(true);
370
+
371
+ const enriched = enrichDetachedStatus(record);
372
+ expect(enriched.status).toBe('executed');
373
+ expect(enriched.exitCode).toBe(137);
374
+ expect(enriched.endTime).not.toBeNull();
375
+ });
376
+
377
+ it('honors the log footer exit code even without a recorded exit code', () => {
378
+ if (!screenAvailable) {
379
+ return;
380
+ }
381
+ const record = new ExecutionRecord({
382
+ command: 'sleep 60',
383
+ logPath,
384
+ options: {
385
+ sessionName,
386
+ isolated: 'screen',
387
+ isolationMode: 'detached',
388
+ },
389
+ });
390
+ // Record was never reconciled: status 'executed' but exitCode still null.
391
+ record.status = 'executed';
392
+ record.exitCode = null;
393
+ record.endTime = null;
394
+
395
+ expect(isDetachedSessionAlive(record)).toBe(true);
396
+
397
+ const enriched = enrichDetachedStatus(record);
398
+ // Footer says 137, so it must stay finished, not flip to executing.
399
+ expect(enriched.status).toBe('executed');
400
+ });
401
+
402
+ it('flips to executing only when there is no terminal record at all', () => {
403
+ if (!screenAvailable) {
404
+ return;
405
+ }
406
+ // Log with NO Exit Code footer and no recorded exit code.
407
+ fs.writeFileSync(logPath, 'still running, no footer yet\n');
408
+ const record = new ExecutionRecord({
409
+ command: 'sleep 60',
410
+ logPath,
411
+ options: {
412
+ sessionName,
413
+ isolated: 'screen',
414
+ isolationMode: 'detached',
415
+ },
416
+ });
417
+ record.status = 'executed';
418
+ record.exitCode = null;
419
+ record.endTime = null;
420
+
421
+ expect(isDetachedSessionAlive(record)).toBe(true);
422
+
423
+ const enriched = enrichDetachedStatus(record);
424
+ expect(enriched.status).toBe('executing');
425
+ expect(enriched.exitCode).toBeNull();
426
+ expect(enriched.endTime).toBeNull();
427
+ });
428
+ });
429
+ });
430
+ });
431
+
432
+ // Issue #136: a detached docker session must not be reported with a terminal
433
+ // status (`executed`) and the `-1` sentinel while its container is still
434
+ // running (or not visible yet on a slow Docker-in-Docker host).
435
+ describe('Issue #136: detached docker session liveness', () => {
436
+ // Use the repo's own probe: `docker` may be installed yet unable to run Linux
437
+ // images (e.g. Windows runners in Windows-containers mode, where `alpine`
438
+ // never starts). In that case `docker inspect` fails and liveness is `null`
439
+ // (unknown) rather than `false`, which would break the stopped-container
440
+ // assertions below.
441
+ const { canRunLinuxDockerImages } = require('../src/lib/isolation');
442
+ const dockerAvailable = canRunLinuxDockerImages();
443
+
444
+ // Whether the container actually exists (was created) per `docker inspect`.
445
+ function dockerContainerExists(name) {
446
+ const probe = spawnSync('docker', ['inspect', name], { stdio: 'ignore' });
447
+ return probe.status === 0;
448
+ }
449
+
450
+ function makeDockerRecord(sessionName, extra = {}) {
451
+ return new ExecutionRecord({
452
+ command: 'sleep 120; echo done',
453
+ options: {
454
+ sessionName,
455
+ isolated: 'docker',
456
+ isolationMode: 'detached',
457
+ },
458
+ ...extra,
459
+ });
460
+ }
461
+
462
+ function dockerRm(name) {
463
+ spawnSync('docker', ['rm', '-f', name], { stdio: 'ignore' });
464
+ }
465
+
466
+ it('reports unknown (null) — not false — when the container is not visible yet', () => {
467
+ if (!dockerAvailable) {
468
+ return;
469
+ }
470
+ const record = makeDockerRecord('issue136-container-does-not-exist-yet');
471
+ expect(isDetachedSessionAlive(record)).toBeNull();
472
+ });
473
+
474
+ it('keeps the record executing while the container is not visible yet', () => {
475
+ if (!dockerAvailable) {
476
+ return;
477
+ }
478
+ const record = makeDockerRecord('issue136-container-not-visible');
479
+ // Defaults to executing with a null exit code.
480
+ const enriched = enrichDetachedStatus(record);
481
+ expect(enriched.status).toBe('executing');
482
+ expect(enriched.exitCode).toBeNull();
483
+ expect(enriched.endTime).toBeNull();
484
+ });
485
+
486
+ it('keeps a running container executing', () => {
487
+ if (!dockerAvailable) {
488
+ return;
489
+ }
490
+ const name = `issue136-running-${process.pid}`;
491
+ dockerRm(name);
492
+ const started = spawnSync('docker', [
493
+ 'run',
494
+ '-d',
495
+ '--name',
496
+ name,
497
+ 'alpine',
498
+ 'sh',
499
+ '-c',
500
+ 'sleep 30',
501
+ ]);
502
+ if (started.status !== 0) {
503
+ dockerRm(name);
504
+ return;
505
+ }
506
+ try {
507
+ const record = makeDockerRecord(name);
508
+ expect(isDetachedSessionAlive(record)).toBe(true);
509
+ const enriched = enrichDetachedStatus(record);
510
+ expect(enriched.status).toBe('executing');
511
+ expect(enriched.exitCode).toBeNull();
512
+ } finally {
513
+ dockerRm(name);
514
+ }
515
+ });
516
+
517
+ it('resolves a stopped container to its real exit code, never the -1 sentinel', () => {
518
+ if (!dockerAvailable) {
519
+ return;
520
+ }
521
+ const name = `issue136-stopped-${process.pid}`;
522
+ dockerRm(name);
523
+ const ran = spawnSync('docker', [
524
+ 'run',
525
+ '--name',
526
+ name,
527
+ 'alpine',
528
+ 'sh',
529
+ '-c',
530
+ 'exit 1',
531
+ ]);
532
+ // `docker run` exits with the container's code (1 here); treat spawn errors
533
+ // (no daemon) or a container that never materialized (e.g. the Linux image
534
+ // could not be pulled) as a skip — there is nothing stopped to inspect.
535
+ if (ran.error || !dockerContainerExists(name)) {
536
+ dockerRm(name);
537
+ return;
538
+ }
539
+ try {
540
+ // No log footer: force exit-code resolution through `docker inspect`.
541
+ const record = makeDockerRecord(name, {
542
+ logPath: '/nonexistent-issue136.log',
543
+ });
544
+ expect(isDetachedSessionAlive(record)).toBe(false);
545
+ const enriched = enrichDetachedStatus(record);
546
+ expect(enriched.status).toBe('executed');
547
+ expect(enriched.exitCode).toBe(1);
548
+ expect(enriched.endTime).not.toBeNull();
549
+ } finally {
550
+ dockerRm(name);
551
+ }
309
552
  });
310
553
  });
311
554