start-command 0.36.0 → 0.37.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +6 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +5 -0
  5. package/src/lib/args-parser-queries.js +14 -0
  6. package/src/lib/args-parser.js +20 -1
  7. package/src/lib/command-builder.js +3 -0
  8. package/src/lib/cpu-penalty-monitor.js +209 -87
  9. package/src/lib/cpu-penalty.js +30 -1
  10. package/src/lib/docker-cleanup.js +12 -5
  11. package/src/lib/docker-command-handoff.js +75 -0
  12. package/src/lib/docker-labels.js +33 -0
  13. package/src/lib/docker-runtime-args.js +8 -0
  14. package/src/lib/docker-snapshot-safety.js +164 -0
  15. package/src/lib/execution-recovery.js +8 -1
  16. package/src/lib/execution-resume.js +184 -37
  17. package/src/lib/failure-handler.js +15 -5
  18. package/src/lib/isolation.js +20 -4
  19. package/src/lib/log-sanitizer.js +267 -0
  20. package/src/lib/log-uploader.js +38 -19
  21. package/src/lib/query-commands.js +4 -3
  22. package/src/lib/resume-resources.js +15 -3
  23. package/src/lib/shell-utils.js +67 -17
  24. package/src/lib/store-lock.js +54 -3
  25. package/src/lib/usage.js +5 -1
  26. package/test/ci-workflow-invariants.js +15 -14
  27. package/test/docker-command-handoff.js +107 -0
  28. package/test/docker-runtime-options.js +4 -0
  29. package/test/docker-snapshot-safety.js +106 -0
  30. package/test/execution-resume.js +10 -8
  31. package/test/failure-handler.js +11 -0
  32. package/test/javascript-first-ci.js +216 -0
  33. package/test/log-sanitizer.js +306 -0
  34. package/test/regression-176.js +7 -0
  35. package/test/regression-187.js +9 -1
  36. package/test/regression-191.js +9 -1
  37. package/test/regression-193.js +11 -1
  38. package/test/regression-198.js +281 -0
  39. package/test/regression-199.js +79 -0
  40. package/test/regression-201.js +25 -0
  41. package/test/regression-202.js +92 -0
  42. package/test/regression-207.js +249 -0
  43. package/test/regression-91.js +23 -20
  44. package/test/shared-golden.js +77 -0
  45. package/test/status-query.js +97 -15
@@ -0,0 +1,281 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+ const { resumeExecution } = require('../src/lib/execution-resume');
8
+ const { recoverKilledExecution } = require('../src/lib/execution-recovery');
9
+ const { SessionState } = require('../src/lib/session-probe');
10
+ const {
11
+ buildDetachedDockerCompletionScript,
12
+ DOCKER_CONTAINER_CLEANUP_POLICY,
13
+ } = require('../src/lib/docker-cleanup');
14
+
15
+ function fixture(options = {}) {
16
+ let current = {
17
+ uuid: 'issue-198-uuid',
18
+ command: 'original',
19
+ status: 'executed',
20
+ exitCode: 137,
21
+ options: {
22
+ isolated: 'docker',
23
+ isolationMode: 'detached',
24
+ sessionName: 'box',
25
+ containerId: 'same-id',
26
+ ...options,
27
+ },
28
+ };
29
+ const store = {
30
+ get: () => globalThis.structuredClone(current),
31
+ save: (r) => {
32
+ current = globalThis.structuredClone(r);
33
+ },
34
+ };
35
+ return { store, current: () => current };
36
+ }
37
+
38
+ function mockDocker(calls, scripts = []) {
39
+ return (_bin, args) => {
40
+ calls.push(args);
41
+ if (args[0] === 'cp') {
42
+ scripts.push(fs.readFileSync(args[1], 'utf8'));
43
+ }
44
+ return {
45
+ success: true,
46
+ stdout:
47
+ args[0] === 'info'
48
+ ? JSON.stringify({ DockerRootDir: '/docker' })
49
+ : args.includes('--size')
50
+ ? '1024'
51
+ : args.includes('{{.State.Running}}')
52
+ ? 'true'
53
+ : args.includes('{{json .HostConfig}}')
54
+ ? '{}'
55
+ : args[0] === 'run'
56
+ ? 'new-container-id'
57
+ : '',
58
+ status: 0,
59
+ };
60
+ };
61
+ }
62
+
63
+ const stopped = () => ({ state: SessionState.STOPPED, alive: false });
64
+
65
+ test('replacement command resumes the same container without a snapshot or changed ID', async () => {
66
+ const { store, current } = fixture({ commandHandoff: true });
67
+ const calls = [],
68
+ scripts = [];
69
+ const result = await resumeExecution(store, 'box', {
70
+ command: "printf '%s' 'new $command'",
71
+ probe: stopped,
72
+ runner: mockDocker(calls, scripts),
73
+ startWatcher: () => true,
74
+ });
75
+ assert.equal(result.success, true, result.error);
76
+ assert.ok(calls.some((args) => args[0] === 'cp'));
77
+ assert.ok(calls.some((args) => args[0] === 'start'));
78
+ assert.ok(
79
+ !calls.some((args) => ['commit', 'run', 'create'].includes(args[0]))
80
+ );
81
+ assert.equal(current().options.containerId, 'same-id');
82
+ assert.equal(current().options.sessionName, 'box');
83
+ assert.ok(scripts[0].includes('new $command'));
84
+ });
85
+
86
+ test('automatic recovery replaces a prior explicit handoff and exports its attempt', () => {
87
+ const { store } = fixture({
88
+ commandHandoff: true,
89
+ onKillResume: 1,
90
+ recoveryCommand: 'printf recovered',
91
+ });
92
+ const scripts = [];
93
+ const result = recoverKilledExecution({
94
+ store,
95
+ executionId: 'issue-198-uuid',
96
+ exitCode: 137,
97
+ oomKilled: false,
98
+ runner: mockDocker([], scripts),
99
+ startWatcher: () => true,
100
+ });
101
+ assert.equal(result.recovered, true, result.reason);
102
+ assert.ok(scripts[0].includes('START_COMMAND_RECOVERY_ATTEMPT=1'));
103
+ assert.ok(scripts[0].includes('printf recovered'));
104
+ });
105
+
106
+ test('legacy snapshots preflight before commit and remove originals only with non-forced rm after start', async () => {
107
+ const { store, current } = fixture();
108
+ const calls = [];
109
+ const result = await resumeExecution(store, 'box', {
110
+ command: 'new',
111
+ probe: stopped,
112
+ removeOriginal: true,
113
+ runner: mockDocker(calls),
114
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
115
+ startWatcher: () => true,
116
+ });
117
+ assert.equal(result.success, true, result.error);
118
+ assert.ok(
119
+ calls.findIndex((a) => a.includes('--size')) <
120
+ calls.findIndex((a) => a[0] === 'commit')
121
+ );
122
+ assert.ok(
123
+ calls.findIndex((a) => a[0] === 'run') <
124
+ calls.findIndex((a) => a[0] === 'rm')
125
+ );
126
+ assert.deepEqual(
127
+ calls.find((a) => a[0] === 'rm'),
128
+ ['rm', 'box']
129
+ );
130
+ assert.equal(current().options.commandHandoff, true);
131
+ assert.equal(current().options.snapshotImage, 'start-command-resume/box:1');
132
+ assert.match(result.output, /snapshotting/);
133
+ });
134
+
135
+ test('disk shortage rolls back the reservation without committing or removing the original', async () => {
136
+ const { store, current } = fixture();
137
+ const calls = [];
138
+ const result = await resumeExecution(store, 'box', {
139
+ command: 'new',
140
+ probe: stopped,
141
+ removeOriginal: true,
142
+ runner: mockDocker(calls),
143
+ snapshotOptions: { freeBytes: () => 0 },
144
+ startWatcher: () => true,
145
+ });
146
+ assert.equal(result.success, false);
147
+ assert.match(result.error, /Insufficient disk/);
148
+ assert.ok(!calls.some((a) => ['commit', 'rm', 'rmi'].includes(a[0])));
149
+ assert.equal(current().options.sessionName, 'box');
150
+ assert.equal(current().options.launchPending, undefined);
151
+ });
152
+
153
+ test('snapshot failure removes a completed image and restores the original execution', async () => {
154
+ const { store, current } = fixture();
155
+ const calls = [];
156
+ const base = mockDocker(calls);
157
+ const result = await resumeExecution(store, 'box', {
158
+ command: 'new',
159
+ probe: stopped,
160
+ runner: (bin, args) =>
161
+ args[0] === 'run'
162
+ ? { success: false, stderr: 'launch failed' }
163
+ : base(bin, args),
164
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
165
+ startWatcher: () => true,
166
+ });
167
+ assert.equal(result.success, false);
168
+ assert.ok(
169
+ calls.some((a) => a[0] === 'rmi' && a[1] === 'start-command-resume/box:1')
170
+ );
171
+ assert.equal(current().options.sessionName, 'box');
172
+ });
173
+
174
+ test('watcher removes its generated image after the cleanup policy removes its container', () => {
175
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cleanup-198-'));
176
+ const watcher = path.join(dir, 'watcher.sh');
177
+ const events = path.join(dir, 'events');
178
+ try {
179
+ fs.writeFileSync(
180
+ watcher,
181
+ `#!/bin/sh
182
+ docker() {
183
+ case "$1" in
184
+ wait) echo 0 ;;
185
+ inspect) case "$3" in
186
+ *State.Running*) echo false ;;
187
+ *State.ExitCode*) echo '0 false 2026-10-10T00:00:00Z 2026-10-10T00:00:01Z' ;;
188
+ *snapshot-image*) echo 'start-command-resume/box:1' ;;
189
+ *) echo '' ;;
190
+ esac ;;
191
+ rm|rmi) printf '%s\\n' "$*" >> events ;;
192
+ esac
193
+ }
194
+ ${buildDetachedDockerCompletionScript(
195
+ 'box-resume-1',
196
+ DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS,
197
+ null
198
+ )}
199
+ `
200
+ );
201
+ // Keep POSIX quoting inside a file, out of Windows process argv/PATH.
202
+ const result = spawnSync('sh', ['watcher.sh'], {
203
+ cwd: dir,
204
+ encoding: 'utf8',
205
+ });
206
+ assert.equal(result.status, 0, result.stderr);
207
+ assert.deepEqual(fs.readFileSync(events, 'utf8').trim().split('\n'), [
208
+ 'rm -f box-resume-1',
209
+ 'rmi start-command-resume/box:1',
210
+ ]);
211
+ } finally {
212
+ fs.rmSync(dir, { recursive: true, force: true });
213
+ }
214
+ });
215
+
216
+ test('changed caller labels use a guarded snapshot and reapply merged labels', async () => {
217
+ const { store, current } = fixture({
218
+ commandHandoff: true,
219
+ labels: ['role=old', 'team=runtime'],
220
+ });
221
+ const calls = [];
222
+ const result = await resumeExecution(store, 'box', {
223
+ probe: stopped,
224
+ resourceOptions: { labels: ['role=new'] },
225
+ runner: mockDocker(calls),
226
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
227
+ startWatcher: () => true,
228
+ });
229
+ assert.equal(result.success, true, result.error);
230
+ const run = calls.find((args) => args[0] === 'run');
231
+ assert.ok(run.includes('role=new'));
232
+ assert.ok(run.includes('team=runtime'));
233
+ assert.ok(!run.includes('role=old'));
234
+ assert.ok(calls.some((args) => args[0] === 'commit'));
235
+ assert.deepEqual(current().options.labels, ['role=new', 'team=runtime']);
236
+ assert.equal(current().options.commandHandoff, true);
237
+ });
238
+
239
+ test('original retained by default and when a successor has already stopped', async () => {
240
+ for (const removeOriginal of [false, true]) {
241
+ const { store } = fixture();
242
+ const calls = [];
243
+ const base = mockDocker(calls);
244
+ const result = await resumeExecution(store, 'box', {
245
+ command: 'new',
246
+ probe: stopped,
247
+ removeOriginal,
248
+ runner: (bin, args) =>
249
+ args.includes('{{.State.Running}}')
250
+ ? { success: true, stdout: 'false' }
251
+ : base(bin, args),
252
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
253
+ startWatcher: () => true,
254
+ });
255
+ assert.equal(result.success, true, result.error);
256
+ assert.ok(!calls.some((args) => args[0] === 'rm'));
257
+ }
258
+ });
259
+
260
+ test('repeated snapshot successors retain the original root-session attribution', async () => {
261
+ const { store, current } = fixture();
262
+ for (const role of ['first', 'second']) {
263
+ const calls = [];
264
+ const result = await resumeExecution(store, 'box', {
265
+ command: 'new',
266
+ probe: stopped,
267
+ resourceOptions: { labels: [`role=${role}`] },
268
+ runner: mockDocker(calls),
269
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
270
+ startWatcher: () => true,
271
+ });
272
+ assert.equal(result.success, true, result.error);
273
+ assert.ok(
274
+ calls
275
+ .find((args) => args[0] === 'run')
276
+ .includes('start-command.root-session=box')
277
+ );
278
+ assert.equal(current().options.rootSession, 'box');
279
+ }
280
+ assert.equal(current().options.resumeCount, 2);
281
+ });
@@ -0,0 +1,79 @@
1
+ const { describe, it, expect } = require('bun:test');
2
+ const { parseArgs } = require('../src/lib/args-parser');
3
+ const {
4
+ buildDockerRuntimeArgs,
5
+ buildDockerRuntimeMetadata,
6
+ } = require('../src/lib/docker-runtime-args');
7
+ const { buildNextLevelCommand } = require('../src/lib/command-builder');
8
+
9
+ describe('Docker attribution labels (#199)', () => {
10
+ it('attributes replacement containers to their stable root and execution', () => {
11
+ const { dockerLabels } = require('../src/lib/docker-labels');
12
+ expect(
13
+ dockerLabels({ session: 'demo-resume-1', sessionId: 'uuid' })
14
+ ).toContain('start-command.root-session=demo-resume-1');
15
+ expect(
16
+ dockerLabels({
17
+ session: 'demo-resume-1-resume-2',
18
+ sessionId: 'uuid',
19
+ resumeCount: 2,
20
+ })
21
+ ).toEqual([
22
+ 'start-command.session=demo-resume-1-resume-2',
23
+ 'start-command.root-session=demo',
24
+ 'start-command.resume-count=2',
25
+ 'start-command.uuid=uuid',
26
+ ]);
27
+ });
28
+ it('parses repeated labels and preserves equals signs and empty values', () => {
29
+ const { wrapperOptions } = parseArgs([
30
+ '-i',
31
+ 'docker',
32
+ '--label',
33
+ 'hive-mind.tool=codex',
34
+ '--label=task.url=https://github.com/o/r/issues/1?q=a=b',
35
+ '--label',
36
+ 'empty=',
37
+ '--',
38
+ 'echo',
39
+ ]);
40
+ expect(wrapperOptions.labels).toEqual([
41
+ 'hive-mind.tool=codex',
42
+ 'task.url=https://github.com/o/r/issues/1?q=a=b',
43
+ 'empty=',
44
+ ]);
45
+ const args = buildDockerRuntimeArgs(wrapperOptions);
46
+ for (const label of wrapperOptions.labels) {
47
+ expect(args).toContain(label);
48
+ }
49
+ expect(buildDockerRuntimeMetadata(wrapperOptions).labels).toEqual(
50
+ wrapperOptions.labels
51
+ );
52
+ });
53
+ it('rejects malformed, reserved and non-Docker labels', () => {
54
+ for (const label of [
55
+ 'missing-value',
56
+ '=empty-key',
57
+ 'start-command.uuid=spoof',
58
+ ]) {
59
+ expect(() =>
60
+ parseArgs(['-i', 'docker', '--label', label, '--', 'true'])
61
+ ).toThrow();
62
+ }
63
+ expect(() => parseArgs(['--label', 'task=one', '--', 'true'])).toThrow();
64
+ });
65
+ it('forwards labels safely through nested Docker isolation', () => {
66
+ const command = buildNextLevelCommand(
67
+ {
68
+ isolatedStack: ['screen', 'docker'],
69
+ labels: ['task=a "quote" $value'],
70
+ },
71
+ 'true'
72
+ );
73
+ expect(
74
+ parseArgs(
75
+ require('../src/lib/shell-utils').splitShellWords(command).slice(1)
76
+ ).wrapperOptions.labels
77
+ ).toEqual(['task=a "quote" $value']);
78
+ });
79
+ });
@@ -0,0 +1,25 @@
1
+ const { describe, it, expect } = require('bun:test');
2
+ const { spawnSync } = require('child_process');
3
+ const path = require('path');
4
+ const { parseArgs } = require('../src/lib/args-parser');
5
+
6
+ describe('wrapper help (#201)', () => {
7
+ for (const flag of ['--help', '-h']) {
8
+ it(`${flag} prints the no-argument usage and exits successfully`, () => {
9
+ const cli = path.join(__dirname, '../src/bin/cli.js');
10
+ const usage = spawnSync(process.execPath, [cli], { encoding: 'utf8' });
11
+ const result = spawnSync(process.execPath, [cli, flag], {
12
+ encoding: 'utf8',
13
+ });
14
+ expect(result.status).toBe(0);
15
+ expect(result.stderr).toBe('');
16
+ expect(result.stdout).toBe(usage.stdout);
17
+ expect(parseArgs([flag]).wrapperOptions.help).toBe(true);
18
+ });
19
+ it(`${flag} after the separator remains part of the command`, () => {
20
+ const result = parseArgs(['--', 'grep', flag]);
21
+ expect(result.wrapperOptions.help).toBe(false);
22
+ expect(result.rawCommand).toEqual(['grep', flag]);
23
+ });
24
+ }
25
+ });
@@ -0,0 +1,92 @@
1
+ const { describe, it, expect } = require('bun:test');
2
+ const { spawnSync } = require('child_process');
3
+ const {
4
+ isInteractiveShellCommand,
5
+ isShellInvocationWithArgs,
6
+ buildShellWithArgsCmdArgs,
7
+ buildDisplayCommand,
8
+ } = require('../src/lib/shell-utils');
9
+
10
+ describe('shell command semantics (#202)', () => {
11
+ for (const operator of [';', '&&', '||', '|', '&', '>', '<', '\n']) {
12
+ it(`keeps the whole command line when it contains ${JSON.stringify(operator)}`, () => {
13
+ const command = `sh -c 'exit 7'${operator} echo after $?`;
14
+ expect(isShellInvocationWithArgs(command)).toBe(false);
15
+ expect(buildDisplayCommand(command)).toBe(command);
16
+ expect(isInteractiveShellCommand(`sh ${operator} echo after`)).toBe(
17
+ false
18
+ );
19
+ });
20
+ }
21
+ it('preserves the script, empty positional argv, quotes, and quoted operators', () => {
22
+ const command = `sh -c 'printf "%s\\n" "$0" "$1" "$2"; echo "|"' a 'b c' ''`;
23
+ expect(isShellInvocationWithArgs(command)).toBe(true);
24
+ expect(buildShellWithArgsCmdArgs(command)).toEqual([
25
+ 'sh',
26
+ '-c',
27
+ 'printf "%s\\n" "$0" "$1" "$2"; echo "|"',
28
+ 'a',
29
+ 'b c',
30
+ '',
31
+ ]);
32
+ });
33
+ it('does not classify a missing script or a script-file argument as -c', () => {
34
+ expect(isShellInvocationWithArgs('sh -c')).toBe(false);
35
+ expect(isShellInvocationWithArgs("sh file -c 'echo hi'")).toBe(false);
36
+ });
37
+ it('retains direct bash -i -c behavior from #91', () => {
38
+ expect(buildShellWithArgsCmdArgs('bash -i -c "nvm --version"')).toEqual([
39
+ 'bash',
40
+ '-i',
41
+ '-c',
42
+ 'nvm --version',
43
+ ]);
44
+ });
45
+ it('leaves outer expansions, comments and globbing to the shell', () => {
46
+ for (const command of [
47
+ 'sh -c "echo $$"',
48
+ 'sh -c echo $HOME',
49
+ 'sh -c echo *',
50
+ 'sh -c echo # comment',
51
+ 'sh -c echo {a,b}',
52
+ "sh -c 'echo hi'\u00a0arg",
53
+ 'sh -c echo \\\nhi',
54
+ ]) {
55
+ expect(isShellInvocationWithArgs(command)).toBe(false);
56
+ }
57
+ });
58
+ it('preserves literal escapes within a directly executed script', () => {
59
+ expect(buildShellWithArgsCmdArgs('sh -c "printf \\q"')).toEqual([
60
+ 'sh',
61
+ '-c',
62
+ 'printf \\q',
63
+ ]);
64
+ expect(buildShellWithArgsCmdArgs('sh -c "echo \\$0" a')).toEqual([
65
+ 'sh',
66
+ '-c',
67
+ 'echo $0',
68
+ 'a',
69
+ ]);
70
+ expect(buildShellWithArgsCmdArgs('sh -c echo a\\;b')).toEqual([
71
+ 'sh',
72
+ '-c',
73
+ 'echo',
74
+ 'a;b',
75
+ ]);
76
+ });
77
+ const run = process.platform === 'win32' ? it.skip : it;
78
+ for (const [command, output, status] of [
79
+ ["sh -c 'exit 7'; echo after $?", 'after 7', 0],
80
+ ["sh -c 'echo $0 $1' a b", 'a b', 0],
81
+ ["sh -c 'kill -9 $$'; exit $?", '', 137],
82
+ ]) {
83
+ run(`executes ${command} with the expected output and exit`, () => {
84
+ const args = isShellInvocationWithArgs(command)
85
+ ? buildShellWithArgsCmdArgs(command)
86
+ : ['sh', '-c', command];
87
+ const result = spawnSync(args[0], args.slice(1), { encoding: 'utf8' });
88
+ expect(result.stdout.trim()).toBe(output);
89
+ expect(result.status).toBe(status);
90
+ });
91
+ }
92
+ });