start-command 0.34.0 → 0.35.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.
@@ -505,96 +505,12 @@ const {
505
505
  canRunLinuxDockerImages,
506
506
  } = require('./docker-utils');
507
507
 
508
- /**
509
- * Build the docker run runtime argument list contributed by configurable
510
- * container options: privileged mode, environment variables, volumes/bind
511
- * mounts, --mount specs, and network configuration. Returned in a stable order so they can be spliced
512
- * into the `docker run` argv before the image name.
513
- * @param {object} options - Options (privileged, env, volumes, mounts, network, networkAliases)
514
- * @returns {string[]} Docker CLI arguments
515
- */
516
- function buildDockerRuntimeArgs(options = {}) {
517
- const args = [];
518
- if (options.privileged) {
519
- args.push('--privileged');
520
- }
521
- for (const envVar of options.env || []) {
522
- args.push('-e', envVar);
523
- }
524
- for (const volume of options.volumes || []) {
525
- args.push('-v', volume);
526
- }
527
- for (const mount of options.mounts || []) {
528
- args.push('--mount', mount);
529
- }
530
- const [firstNetwork] = getDockerNetworks(options);
531
- if (firstNetwork) {
532
- args.push('--network', firstNetwork);
533
- }
534
- for (const alias of options.networkAliases || []) {
535
- args.push('--network-alias', alias);
536
- }
537
- return args;
538
- }
539
-
540
- /**
541
- * Build the human-readable `[Isolation]` status lines for docker runtime
542
- * options (volumes, mounts, env, privileged). Empty collections and a falsy
543
- * privileged flag contribute no lines.
544
- * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
545
- * @returns {string[]} Status lines for the start block / log header
546
- */
547
- function buildDockerRuntimeStatusLines(options = {}) {
548
- const lines = [];
549
- if (options.volumes && options.volumes.length > 0) {
550
- lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
551
- }
552
- if (options.mounts && options.mounts.length > 0) {
553
- lines.push(`[Isolation] Mounts: ${options.mounts.join(', ')}`);
554
- }
555
- if (options.env && options.env.length > 0) {
556
- lines.push(`[Isolation] Env: ${options.env.join(', ')}`);
557
- }
558
- if (options.privileged) {
559
- lines.push(`[Isolation] Privileged: true`);
560
- }
561
- const networks = getDockerNetworks(options);
562
- if (networks.length > 0) {
563
- lines.push(`[Isolation] Network: ${networks[0]}`);
564
- }
565
- if (networks.length > 1) {
566
- lines.push(`[Isolation] Networks: ${networks.join(', ')}`);
567
- }
568
- if (options.networkAliases && options.networkAliases.length > 0) {
569
- lines.push(
570
- `[Isolation] Network aliases: ${options.networkAliases.join(', ')}`
571
- );
572
- }
573
- return lines;
574
- }
575
-
576
- /**
577
- * Build the execution-record metadata for docker runtime options, normalizing
578
- * empty collections and a falsy privileged flag to `null`.
579
- * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
580
- * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean, network: ?string, networks: ?string[], networkAliases: ?string[]}}
581
- */
582
- function buildDockerRuntimeMetadata(options = {}) {
583
- const networks = getDockerNetworks(options);
584
- return {
585
- volumes:
586
- options.volumes && options.volumes.length > 0 ? options.volumes : null,
587
- mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
588
- env: options.env && options.env.length > 0 ? options.env : null,
589
- privileged: options.privileged || null,
590
- network: networks[0] || null,
591
- networks: networks.length > 0 ? networks : null,
592
- networkAliases:
593
- options.networkAliases && options.networkAliases.length > 0
594
- ? options.networkAliases
595
- : null,
596
- };
597
- }
508
+ const {
509
+ buildDockerRuntimeArgs,
510
+ buildDockerRuntimeStatusLines,
511
+ buildDockerRuntimeMetadata,
512
+ } = require('./docker-runtime-args');
513
+ const { buildRecoverySelectorArgs } = require('./execution-recovery');
598
514
 
599
515
  /**
600
516
  * Run command in Docker container
@@ -679,11 +595,22 @@ function runInDocker(command, options = {}) {
679
595
  const shellArgs = shellInteractiveFlag
680
596
  ? [shellToUse, shellInteractiveFlag]
681
597
  : [shellToUse];
682
- const cmdArgs = isBareShell
598
+ const mainCmdArgs = isBareShell
683
599
  ? toShellWords(command)
684
600
  : isShellInvocationWithArgs(command)
685
601
  ? buildShellWithArgsCmdArgs(effectiveCommand)
686
602
  : [...shellArgs, '-c', effectiveCommand];
603
+ // A recovery command is selected by a marker that `docker cp` drops
604
+ // into the container before it is started again (issue #176).
605
+ const cmdArgs = options.recoveryCommand
606
+ ? buildRecoverySelectorArgs(mainCmdArgs, {
607
+ shell: shellToUse,
608
+ shellFlag: shellInteractiveFlag,
609
+ recoveryCommand: options.keepAlive
610
+ ? `${options.recoveryCommand}; exec ${shellToUse}`
611
+ : options.recoveryCommand,
612
+ })
613
+ : mainCmdArgs;
687
614
  dockerArgs.push(options.image, ...cmdArgs);
688
615
 
689
616
  if (DEBUG) {
@@ -736,7 +663,8 @@ function runInDocker(command, options = {}) {
736
663
  containerName,
737
664
  cleanupPolicy,
738
665
  options.logPath,
739
- options.executionId || null
666
+ options.executionId || null,
667
+ { recoverOnKill: Boolean(options.onKillResume) }
740
668
  );
741
669
 
742
670
  let message = `Command started in detached docker container: ${containerName}`;
@@ -12,6 +12,7 @@
12
12
  const { spawn } = require('child_process');
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
+ const { resolveChildExitCode } = require('./exit-reason');
15
16
 
16
17
  function ensureParentDirectory(filePath) {
17
18
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -282,8 +283,9 @@ function runWithNodeSpawn(options) {
282
283
  // The 'close' event fires after all stdio streams have been closed, ensuring
283
284
  // all stdout/stderr data has been received. The 'exit' event can fire before
284
285
  // buffered data is received, causing output loss on macOS (Issue #57).
285
- child.on('close', (code) => {
286
- const exitCode = code || 0;
286
+ child.on('close', (code, signal) => {
287
+ // A signal death reports `code === null`: never record it as exit 0.
288
+ const exitCode = resolveChildExitCode(code, signal);
287
289
  const durationMs = Date.now() - startTimeMs;
288
290
  const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
289
291
 
package/src/lib/usage.js CHANGED
@@ -16,6 +16,8 @@ Options:
16
16
  --privileged Run docker container in privileged mode (docker only)
17
17
  --network <name> Connect to a named network (repeatable, docker only)
18
18
  --network-alias <alias> Add alias to the first network (repeatable, docker only)
19
+ --on-kill-resume <N> Resume a killed (exit 137 / OOM) detached docker session up to N times
20
+ --recovery-command <cmd> Command to run in the same container on such a resume
19
21
  --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
20
22
  --isolated-user, -u [name] Create isolated user with same permissions
21
23
  --keep-user Keep isolated user after command completes
@@ -66,6 +68,7 @@ Examples:
66
68
  $ --resume my-docker-session # Restart the stored command in the same container
67
69
  $ --resume my-docker-session -- bash # Run a different command in the same container
68
70
  $ --resume-all # Re-attach every still-running execution
71
+ $ -i docker -d --on-kill-resume 3 --recovery-command 'solve --resume' -- solve
69
72
  $ --use-command-stream echo "Hello" # Use command-stream library`);
70
73
  console.log('');
71
74
  console.log('Piping with $:');
@@ -304,6 +304,9 @@ describe('buildDockerRuntimeMetadata', () => {
304
304
  network: null,
305
305
  networks: null,
306
306
  networkAliases: null,
307
+ resourceLimits: null,
308
+ onKillResume: null,
309
+ recoveryCommand: null,
307
310
  });
308
311
  });
309
312
 
@@ -326,6 +329,9 @@ describe('buildDockerRuntimeMetadata', () => {
326
329
  network: 'hive-formal-ai',
327
330
  networks: ['hive-formal-ai', 'public-egress'],
328
331
  networkAliases: ['formal-ai', 'checker'],
332
+ resourceLimits: null,
333
+ onKillResume: null,
334
+ recoveryCommand: null,
329
335
  }
330
336
  );
331
337
  });
@@ -301,7 +301,10 @@ describe('issue #170.1: the detached watcher persists the terminal state', () =>
301
301
 
302
302
  const stored = store.get(record.uuid);
303
303
  expect(stored.status).toBe(ExecutionStatus.EXECUTED);
304
- expect(stored.exitCode).toBe(0);
304
+ // `ExitCode=0` next to a zero `FinishedAt` is Docker's zero value, not an
305
+ // observed success (issue #174).
306
+ expect(stored.exitCode).toBe(-1);
307
+ expect(stored.exitReason).toBe('watcher-lost-container');
305
308
  expect(stored.endTimeSource).toBe('observed-at');
306
309
  expect(stored.observedAt).toBe(stored.endTime);
307
310
  expect(new Date(stored.endTime).getUTCFullYear()).toBeGreaterThan(2000);
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Regression tests for issue #174: the detached docker watcher `docker rm -f`ed
3
+ * a still-running container when `docker logs -f` failed (ENOSPC) and
4
+ * finalized it as executed / exit 0.
5
+ *
6
+ * - `docker logs -f C >> LOG` also returns when its own write to LOG fails,
7
+ * and on a dockerd restart; the watcher must wait for the real exit;
8
+ * - a container that is still running must never be removed, get an
9
+ * `Exit Code:` footer, or be finalized;
10
+ * - `ExitCode=0` next to a zero `FinishedAt` is Docker's zero value, never an
11
+ * observed exit 0;
12
+ * - a child killed by a signal (`code === null`) must never become exit 0.
13
+ */
14
+
15
+ const { describe, it, expect, afterEach } = require('bun:test');
16
+ const { spawnSync } = require('child_process');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+
21
+ const {
22
+ ExecutionStore,
23
+ ExecutionRecord,
24
+ ExecutionStatus,
25
+ } = require('../src/lib/execution-store');
26
+ const {
27
+ buildDetachedDockerCompletionScript,
28
+ DOCKER_CONTAINER_CLEANUP_POLICY,
29
+ } = require('../src/lib/docker-cleanup');
30
+ const {
31
+ LOG_CAPTURE_STOPPED_NOTE,
32
+ STILL_RUNNING_NOTE,
33
+ } = require('../src/lib/docker-post-mortem');
34
+ const {
35
+ WATCHER_LOST_CONTAINER,
36
+ finalizeDetachedExecution,
37
+ } = require('../src/lib/detached-finalize');
38
+ const { resolveChildExitCode } = require('../src/lib/exit-reason');
39
+ const { runWithNodeSpawn } = require('../src/lib/spawn-helpers');
40
+
41
+ const tempDirs = [];
42
+
43
+ function makeTempDir(prefix) {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
45
+ tempDirs.push(dir);
46
+ return dir;
47
+ }
48
+
49
+ afterEach(() => {
50
+ while (tempDirs.length > 0) {
51
+ const dir = tempDirs.pop();
52
+ fs.rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ function detachedDockerRecord(logPath) {
57
+ return new ExecutionRecord({
58
+ status: ExecutionStatus.EXECUTING,
59
+ command: 'work',
60
+ logPath,
61
+ options: {
62
+ isolated: 'docker',
63
+ isolationMode: 'detached',
64
+ sessionName: 'start-command-174',
65
+ },
66
+ });
67
+ }
68
+
69
+ // The watcher body is a POSIX `sh` script; Windows has no `/bin/sh`.
70
+ const needsPosixShell = () => {
71
+ if (process.platform !== 'win32') {
72
+ return false;
73
+ }
74
+ console.log(' Skipping: the watcher needs a POSIX /bin/sh');
75
+ return true;
76
+ };
77
+
78
+ /**
79
+ * Run the real watcher script against a scripted fake `docker`.
80
+ *
81
+ * `running` lists the successive answers to `docker inspect -f
82
+ * '{{.State.Running}}'` (`true`, `false`, or `fail` for an inspect that
83
+ * errors, as during a dockerd restart); the last answer sticks. `docker logs`
84
+ * writes one line and fails, exactly like `docker logs -f C >> LOG` whose write
85
+ * hit ENOSPC. Every call is recorded, and `rm` records whether the container was
86
+ * running at that moment.
87
+ */
88
+ function runWatcher({ policy, running, state, withLog = true }) {
89
+ const dir = makeTempDir('watcher-174-');
90
+ const binDir = path.join(dir, 'bin');
91
+ const appFolder = path.join(dir, 'app');
92
+ fs.mkdirSync(binDir);
93
+ const logPath = path.join(dir, 'run.log');
94
+ fs.writeFileSync(path.join(dir, 'running'), `${running.join('\n')}\n`);
95
+ fs.writeFileSync(path.join(dir, 'state'), `${state}\n`);
96
+ const calls = path.join(dir, 'calls');
97
+ const dockerPath = path.join(binDir, 'docker');
98
+ fs.writeFileSync(
99
+ dockerPath,
100
+ [
101
+ '#!/bin/sh',
102
+ `dir='${dir}'`,
103
+ 'case "$1" in',
104
+ ' inspect)',
105
+ ' case "$3" in',
106
+ ' *State.Running*)',
107
+ ' answer=$(head -n 1 "$dir/running")',
108
+ ' if [ "$(wc -l < "$dir/running")" -gt 1 ]; then',
109
+ ' tail -n +2 "$dir/running" > "$dir/running.next" && mv "$dir/running.next" "$dir/running"',
110
+ ' fi',
111
+ ' echo "inspect-running $answer" >> "$dir/calls"',
112
+ ' [ "$answer" = fail ] && exit 1',
113
+ ' echo "$answer" > "$dir/current"',
114
+ ' echo "$answer" ;;',
115
+ ' *State.Error*) echo "" ;;',
116
+ ' *) echo "inspect-state" >> "$dir/calls"; cat "$dir/state" ;;',
117
+ ' esac ;;',
118
+ ' logs) echo logs >> "$dir/calls"; echo work; exit 1 ;;',
119
+ ' wait) echo wait >> "$dir/calls"; echo 0 ;;',
120
+ ' rm) echo "rm running=$(cat "$dir/current" 2>/dev/null)" >> "$dir/calls" ;;',
121
+ 'esac',
122
+ '',
123
+ ].join('\n'),
124
+ 'utf8'
125
+ );
126
+ fs.chmodSync(dockerPath, 0o755);
127
+
128
+ const store = new ExecutionStore({ appFolder, useLinks: false });
129
+ const record = detachedDockerRecord(withLog ? logPath : null);
130
+ store.save(record);
131
+
132
+ const script = buildDetachedDockerCompletionScript(
133
+ 'demo',
134
+ policy,
135
+ withLog ? logPath : null,
136
+ record.uuid
137
+ );
138
+ const result = spawnSync('/bin/sh', ['-c', script], {
139
+ encoding: 'utf8',
140
+ env: {
141
+ ...process.env,
142
+ PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
143
+ START_APP_FOLDER: appFolder,
144
+ START_DISABLE_TRACKING: '',
145
+ },
146
+ timeout: 20000,
147
+ });
148
+ return {
149
+ status: result.status,
150
+ calls: fs.readFileSync(calls, 'utf8').trim().split('\n'),
151
+ log: fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '',
152
+ record: new ExecutionStore({ appFolder, useLinks: false }).get(record.uuid),
153
+ };
154
+ }
155
+
156
+ const FINISHED_7 =
157
+ '7 false 2026-09-27T10:00:00.000000000Z 2026-09-27T10:00:05.000000000Z';
158
+ const FINISHED_0 =
159
+ '0 false 2026-09-27T10:00:00.000000000Z 2026-09-27T10:00:05.000000000Z';
160
+ const NOT_FINISHED = '0 false 2026-09-27T10:00:00Z 0001-01-01T00:00:00Z';
161
+
162
+ describe('issue #174: docker logs -f returning is not the container exiting', () => {
163
+ it('waits for the real exit after docker logs -f fails (ENOSPC)', () => {
164
+ if (needsPosixShell()) {
165
+ return;
166
+ }
167
+ const run = runWatcher({
168
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
169
+ running: ['true', 'true', 'false'],
170
+ state: FINISHED_7,
171
+ });
172
+
173
+ expect(run.status).toBe(0);
174
+ expect(run.calls).toContain('wait');
175
+ expect(run.calls.indexOf('wait')).toBeGreaterThan(
176
+ run.calls.indexOf('logs')
177
+ );
178
+ expect(run.calls.indexOf('inspect-state')).toBeGreaterThan(
179
+ run.calls.lastIndexOf('wait')
180
+ );
181
+ expect(run.log).toContain(LOG_CAPTURE_STOPPED_NOTE);
182
+ expect(run.log).toContain('Exit Code: 7');
183
+ expect(run.log).not.toContain('Exit Code: 0');
184
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
185
+ expect(run.record.exitCode).toBe(7);
186
+ expect(run.record.endTimeSource).toBe('docker-finished-at');
187
+ });
188
+
189
+ it('only removes the container once it has really exited', () => {
190
+ if (needsPosixShell()) {
191
+ return;
192
+ }
193
+ const run = runWatcher({
194
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
195
+ running: ['true', 'true', 'false'],
196
+ state: FINISHED_0,
197
+ });
198
+
199
+ expect(run.calls).toContain('rm running=false');
200
+ expect(run.calls).not.toContain('rm running=true');
201
+ expect(run.log).toContain('Exit Code: 0');
202
+ expect(run.record.exitCode).toBe(0);
203
+ });
204
+
205
+ it('keeps waiting across a failed docker inspect (dockerd restart)', () => {
206
+ if (needsPosixShell()) {
207
+ return;
208
+ }
209
+ const run = runWatcher({
210
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
211
+ running: ['true', 'true', 'true', 'false'],
212
+ state: FINISHED_7,
213
+ });
214
+
215
+ expect(run.calls.filter((call) => call === 'wait').length).toBe(2);
216
+ expect(run.record.exitCode).toBe(7);
217
+ });
218
+
219
+ it('applies the same wait to the no-log watcher', () => {
220
+ if (needsPosixShell()) {
221
+ return;
222
+ }
223
+ const run = runWatcher({
224
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
225
+ running: ['true', 'false'],
226
+ state: FINISHED_7,
227
+ withLog: false,
228
+ });
229
+
230
+ expect(run.calls.filter((call) => call === 'wait').length).toBe(2);
231
+ expect(run.calls).toContain('rm running=false');
232
+ expect(run.calls).not.toContain('rm running=true');
233
+ expect(run.record.exitCode).toBe(7);
234
+ });
235
+ });
236
+
237
+ describe('issue #174: a container that is still running is left alone', () => {
238
+ it('never removes, footers or finalizes a running container', () => {
239
+ if (needsPosixShell()) {
240
+ return;
241
+ }
242
+ // logs -f fails -> running; the wait loop's inspect fails (daemon down) ->
243
+ // the loop ends; once the daemon is back the container is still running.
244
+ const run = runWatcher({
245
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
246
+ running: ['true', 'fail', 'true'],
247
+ state: NOT_FINISHED,
248
+ });
249
+
250
+ expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
251
+ expect(run.log).toContain('Container still running: demo');
252
+ expect(run.log).toContain(STILL_RUNNING_NOTE);
253
+ expect(run.log).not.toContain('Exit Code:');
254
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTING);
255
+ expect(run.record.exitCode).toBeNull();
256
+ });
257
+
258
+ it('never removes a running container without a log either', () => {
259
+ if (needsPosixShell()) {
260
+ return;
261
+ }
262
+ const run = runWatcher({
263
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
264
+ running: ['fail', 'true'],
265
+ state: NOT_FINISHED,
266
+ withLog: false,
267
+ });
268
+
269
+ expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
270
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTING);
271
+ });
272
+ });
273
+
274
+ describe('issue #174: a zero FinishedAt is never an exit 0', () => {
275
+ it('keeps the container and records no success in the watcher', () => {
276
+ if (needsPosixShell()) {
277
+ return;
278
+ }
279
+ const run = runWatcher({
280
+ policy: DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT,
281
+ running: ['false'],
282
+ state: NOT_FINISHED,
283
+ });
284
+
285
+ expect(run.calls.some((call) => call.startsWith('rm'))).toBe(false);
286
+ expect(run.log).toContain('Exit Code: -1');
287
+ expect(run.log).not.toContain('Exit Code: 0');
288
+ expect(run.record.status).toBe(ExecutionStatus.EXECUTED);
289
+ expect(run.record.exitCode).toBe(-1);
290
+ expect(run.record.exitReason).toBe(WATCHER_LOST_CONTAINER);
291
+ expect(run.record.endTimeSource).toBe('observed-at');
292
+ });
293
+
294
+ it('refuses to finalize a record whose container is still running', () => {
295
+ const appFolder = makeTempDir('finalize-174-running-');
296
+ const store = new ExecutionStore({ appFolder, useLinks: false });
297
+ const record = detachedDockerRecord(null);
298
+ store.save(record);
299
+
300
+ const outcome = finalizeDetachedExecution({
301
+ store,
302
+ executionId: record.uuid,
303
+ exitCode: '0',
304
+ oomKilled: 'false',
305
+ startedAt: '2026-09-27T10:00:00Z',
306
+ finishedAt: '0001-01-01T00:00:00Z',
307
+ containerError: '',
308
+ running: 'true',
309
+ });
310
+
311
+ expect(outcome).toEqual({ updated: false, reason: 'still-running' });
312
+ expect(store.get(record.uuid).status).toBe(ExecutionStatus.EXECUTING);
313
+ });
314
+
315
+ it('does not trust exit 0 without a FinishedAt', () => {
316
+ const appFolder = makeTempDir('finalize-174-zero-');
317
+ const store = new ExecutionStore({ appFolder, useLinks: false });
318
+ const record = detachedDockerRecord(null);
319
+ store.save(record);
320
+
321
+ finalizeDetachedExecution({
322
+ store,
323
+ executionId: record.uuid,
324
+ exitCode: '0',
325
+ oomKilled: 'false',
326
+ startedAt: '2026-09-27T10:00:00Z',
327
+ finishedAt: '0001-01-01T00:00:00Z',
328
+ running: 'false',
329
+ });
330
+
331
+ const stored = store.get(record.uuid);
332
+ expect(stored.exitCode).toBe(-1);
333
+ expect(stored.exitReason).toBe(WATCHER_LOST_CONTAINER);
334
+ });
335
+
336
+ it('still records a real exit 0', () => {
337
+ const appFolder = makeTempDir('finalize-174-real-zero-');
338
+ const store = new ExecutionStore({ appFolder, useLinks: false });
339
+ const record = detachedDockerRecord(null);
340
+ store.save(record);
341
+
342
+ finalizeDetachedExecution({
343
+ store,
344
+ executionId: record.uuid,
345
+ exitCode: '0',
346
+ oomKilled: 'false',
347
+ startedAt: '2026-09-27T10:00:00Z',
348
+ finishedAt: '2026-09-27T10:00:05Z',
349
+ running: 'false',
350
+ });
351
+
352
+ const stored = store.get(record.uuid);
353
+ expect(stored.exitCode).toBe(0);
354
+ expect(stored.exitReason).toBeUndefined();
355
+ expect(stored.endTimeSource).toBe('docker-finished-at');
356
+ });
357
+ });
358
+
359
+ describe('issue #174: a signal death is never exit 0', () => {
360
+ it('maps a null code with a signal to 128+n', () => {
361
+ expect(resolveChildExitCode(null, 'SIGKILL')).toBe(137);
362
+ expect(resolveChildExitCode(null, 'SIGTERM')).toBe(143);
363
+ expect(resolveChildExitCode(null, 'SIGINT')).toBe(130);
364
+ });
365
+
366
+ it('maps a missing code without a signal to a failure', () => {
367
+ expect(resolveChildExitCode(null)).toBe(1);
368
+ expect(resolveChildExitCode(undefined)).toBe(1);
369
+ expect(resolveChildExitCode(null, 'SIGNOTREAL')).toBe(1);
370
+ });
371
+
372
+ it('keeps real exit codes, including 0', () => {
373
+ expect(resolveChildExitCode(0)).toBe(0);
374
+ expect(resolveChildExitCode(3, null)).toBe(3);
375
+ });
376
+
377
+ it('records a self-killed child as 137 in the node spawn path', async () => {
378
+ if (needsPosixShell()) {
379
+ return;
380
+ }
381
+ const dir = makeTempDir('spawn-174-');
382
+ const logFilePath = path.join(dir, 'run.log');
383
+ const exitCode = await new Promise((resolve) => {
384
+ runWithNodeSpawn({
385
+ shell: '/bin/sh',
386
+ shellArgs: ['-c', 'kill -KILL $$'],
387
+ logFilePath,
388
+ startTimeMs: Date.now(),
389
+ onComplete: (code) => resolve(code),
390
+ onError: () => resolve('error'),
391
+ });
392
+ });
393
+
394
+ expect(exitCode).toBe(137);
395
+ expect(fs.readFileSync(logFilePath, 'utf8')).toContain('Exit Code: 137');
396
+ });
397
+ });