start-command 0.35.4 → 0.36.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 (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +10 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +43 -7
  5. package/src/lib/args-parser.js +12 -0
  6. package/src/lib/atomic-write.js +34 -0
  7. package/src/lib/attached-diagnostics.js +90 -0
  8. package/src/lib/cgroup-memory.js +43 -11
  9. package/src/lib/command-builder.js +40 -0
  10. package/src/lib/cpu-penalty-monitor.js +226 -0
  11. package/src/lib/cpu-penalty.js +175 -0
  12. package/src/lib/detached-finalize.js +16 -0
  13. package/src/lib/docker-cleanup.js +23 -2
  14. package/src/lib/docker-resource-limits.js +28 -0
  15. package/src/lib/docker-resource-options.js +180 -0
  16. package/src/lib/docker-runtime-args.js +15 -0
  17. package/src/lib/execution-recovery.js +145 -29
  18. package/src/lib/execution-resume-all.js +11 -0
  19. package/src/lib/execution-resume.js +142 -29
  20. package/src/lib/execution-store.js +25 -121
  21. package/src/lib/exit-evidence.js +123 -0
  22. package/src/lib/exit-reason.js +24 -6
  23. package/src/lib/isolation.js +28 -3
  24. package/src/lib/launch-owner.js +25 -0
  25. package/src/lib/launch-persistence.js +54 -0
  26. package/src/lib/query-commands.js +14 -3
  27. package/src/lib/resume-resources.js +88 -0
  28. package/src/lib/status-formatter.js +28 -0
  29. package/src/lib/store-lock.js +133 -0
  30. package/src/lib/usage.js +11 -0
  31. package/test/docker-runtime-options.js +6 -0
  32. package/test/execution-resume.js +9 -3
  33. package/test/exit-reason.js +6 -1
  34. package/test/isolation.js +5 -4
  35. package/test/regression-174.js +3 -1
  36. package/test/regression-180.js +17 -10
  37. package/test/regression-182.js +7 -3
  38. package/test/regression-185.js +4 -9
  39. package/test/regression-187.js +2 -1
  40. package/test/regression-189.js +224 -0
  41. package/test/regression-190.js +111 -0
  42. package/test/regression-191.js +152 -0
  43. package/test/regression-192.js +66 -0
  44. package/test/regression-193.js +241 -0
  45. package/test/regression-194.js +182 -0
@@ -0,0 +1,111 @@
1
+ const { describe, it, expect } = require('bun:test');
2
+ const { parseArgs } = require('../src/lib/args-parser');
3
+
4
+ describe('issue #190: launch resource options', () => {
5
+ it('recognizes resource flags and rejects invalid input before launch', () => {
6
+ const parsed = parseArgs([
7
+ '--isolated',
8
+ 'docker',
9
+ '--image',
10
+ 'alpine',
11
+ '--memory',
12
+ '90%-100%',
13
+ '--cpus=50%',
14
+ '--',
15
+ 'true',
16
+ ]);
17
+ expect(parsed.wrapperOptions.memory).toBe('90%-100%');
18
+ expect(parsed.wrapperOptions.cpus).toBe('50%');
19
+ for (const value of ['100%-90%', '101%', '0%', '0g', '3watts']) {
20
+ expect(() =>
21
+ parseArgs([
22
+ '--isolated',
23
+ 'docker',
24
+ '--image',
25
+ 'alpine',
26
+ '--memory',
27
+ value,
28
+ '--',
29
+ 'true',
30
+ ])
31
+ ).toThrow();
32
+ }
33
+ });
34
+ });
35
+
36
+ const {
37
+ resolveResourceOptions,
38
+ } = require('../src/lib/docker-resource-options');
39
+ it('uses daemon capacity, draws each range once and disables extra swap by default', () => {
40
+ let draws = 0;
41
+ const calls = [];
42
+ const result = resolveResourceOptions(
43
+ { memory: '70%-80%', cpus: '50%' },
44
+ (_cmd, args) => {
45
+ calls.push(args);
46
+ return {
47
+ success: true,
48
+ stdout: JSON.stringify({ MemTotal: 1024 ** 3 * 10, NCPU: 8 }),
49
+ };
50
+ },
51
+ () => {
52
+ draws++;
53
+ return 0.5;
54
+ }
55
+ );
56
+ expect(result.resourceLimits).toEqual([
57
+ '--memory=8053063680',
58
+ '--cpus=4',
59
+ '--memory-swap=8053063680',
60
+ ]);
61
+ expect(result.resolvedLimits.memorySwap).toBe(result.resolvedLimits.memory);
62
+ expect(calls).toHaveLength(1);
63
+ expect(draws).toBe(2);
64
+ });
65
+ it('preserves stored resolutions without another random draw or daemon query', () => {
66
+ const limits = [
67
+ '--memory=8053063680',
68
+ '--memory-swap=8053063680',
69
+ '--cpus=4',
70
+ ];
71
+ const result = resolveResourceOptions(
72
+ { resourceLimits: limits },
73
+ () => {
74
+ throw Error('unexpected query');
75
+ },
76
+ () => {
77
+ throw Error('unexpected draw');
78
+ }
79
+ );
80
+ expect(result.resourceLimits).toEqual(limits);
81
+ });
82
+ it('rejects swap below memory and daemon capacity failures', () => {
83
+ expect(() =>
84
+ resolveResourceOptions({ memory: '64m', memorySwap: '32m' })
85
+ ).toThrow();
86
+ expect(() =>
87
+ resolveResourceOptions({ memory: '80%' }, () => ({ success: false }))
88
+ ).toThrow();
89
+ });
90
+
91
+ it('forwards raw resource and penalty options through every remaining Docker level', () => {
92
+ const { buildNextLevelCommand } = require('../src/lib/command-builder');
93
+ const options = parseArgs([
94
+ '--isolated',
95
+ 'screen docker',
96
+ '--memory',
97
+ '70%-80%',
98
+ '--cpus',
99
+ '50%',
100
+ '--cpu-penalty',
101
+ '--cpu-penalty-trigger-window',
102
+ '2s',
103
+ '--',
104
+ 'work',
105
+ ]).wrapperOptions;
106
+ const command = buildNextLevelCommand(options, 'work');
107
+ const words = require('../src/lib/shell-utils').splitShellWords(command);
108
+ expect(words[words.indexOf('--memory') + 1]).toBe('70%-80%');
109
+ expect(words[words.indexOf('--cpus') + 1]).toBe('50%');
110
+ expect(command).toContain('--cpu-penalty-trigger-window 2000ms');
111
+ });
@@ -0,0 +1,152 @@
1
+ const { it, expect } = require('bun:test');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const {
6
+ ExecutionStore,
7
+ ExecutionRecord,
8
+ } = require('../src/lib/execution-store');
9
+ const { resumeExecution } = require('../src/lib/execution-resume');
10
+ const { recoverKilledExecution } = require('../src/lib/execution-recovery');
11
+ const { SessionState } = require('../src/lib/session-probe');
12
+
13
+ function fixture() {
14
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-191-'));
15
+ const store = new ExecutionStore({ appFolder: dir, useLinks: false });
16
+ const record = new ExecutionRecord({
17
+ command: 'work',
18
+ status: 'executed',
19
+ logPath: path.join(dir, 'run.log'),
20
+ options: {
21
+ isolated: 'docker',
22
+ isolationMode: 'detached',
23
+ sessionName: 'task',
24
+ image: 'alpine',
25
+ onKillResume: 3,
26
+ onKillResumeMemory: '70%-80%',
27
+ },
28
+ });
29
+ store.save(record);
30
+ const calls = [];
31
+ const runner = (_, args) => {
32
+ calls.push(args);
33
+ if (args[0] === 'info') {
34
+ return {
35
+ success: true,
36
+ stdout: JSON.stringify({ MemTotal: 1073741824, NCPU: 4 }),
37
+ };
38
+ }
39
+ if (args[0] === 'inspect') {
40
+ return {
41
+ success: true,
42
+ stdout: JSON.stringify({
43
+ Memory: 67108864,
44
+ MemorySwap: 67108864,
45
+ NanoCpus: 3000000000,
46
+ }),
47
+ };
48
+ }
49
+ return { success: true, stdout: 'id' };
50
+ };
51
+ return { dir, store, record, runner, calls };
52
+ }
53
+ it('manual in-place memory override updates before start and records the resolution', async () => {
54
+ const f = fixture();
55
+ try {
56
+ const result = await resumeExecution(f.store, f.record.uuid, {
57
+ resourceOptions: { memory: '128m' },
58
+ runner: f.runner,
59
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
60
+ startWatcher: () => true,
61
+ });
62
+ expect(result.success).toBe(true);
63
+ const update = f.calls.find((a) => a[0] === 'update');
64
+ expect(update).toContain('--memory=134217728');
65
+ expect(update).toContain('--memory-swap=134217728');
66
+ expect(f.calls.indexOf(update)).toBeLessThan(
67
+ f.calls.findIndex((a) => a[0] === 'start')
68
+ );
69
+ expect(f.store.get(f.record.uuid).options.resolvedLimits.memory).toBe(
70
+ 134217728
71
+ );
72
+ } finally {
73
+ fs.rmSync(f.dir, { recursive: true, force: true });
74
+ }
75
+ });
76
+ it('manual snapshot override applies limits to the new container at creation', async () => {
77
+ const f = fixture();
78
+ try {
79
+ const result = await resumeExecution(f.store, f.record.uuid, {
80
+ command: 'new',
81
+ resourceOptions: { memory: '128m' },
82
+ runner: f.runner,
83
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
84
+ startWatcher: () => true,
85
+ });
86
+ expect(result.success).toBe(true);
87
+ expect(f.calls.find((a) => a[0] === 'run')).toContain('--memory=134217728');
88
+ } finally {
89
+ fs.rmSync(f.dir, { recursive: true, force: true });
90
+ }
91
+ });
92
+ for (const [exitCode, oomKilled, sample, change] of [
93
+ [137, true, '67108864 66000000 1 1 100 100', true],
94
+ [137, true, '67108864 66000000 1 1 0 100', false],
95
+ [137, false, '67108864 66000000 0 0 0 100', false],
96
+ [1, true, '67108864 66000000 1 1 100 100', false],
97
+ ]) {
98
+ it(`automatic memory change requires a qualifying OOM (exit=${exitCode}, fresh=${change})`, () => {
99
+ const f = fixture();
100
+ try {
101
+ const result = recoverKilledExecution({
102
+ store: f.store,
103
+ executionId: f.record.uuid,
104
+ exitCode,
105
+ oomKilled,
106
+ cgroupMemory: sample,
107
+ finishedAt: '1970-01-01T00:01:40Z',
108
+ runner: f.runner,
109
+ random: () => 0.5,
110
+ startWatcher: () => true,
111
+ });
112
+ const update = f.calls.find((a) => a[0] === 'update');
113
+ expect(Boolean(update)).toBe(change);
114
+ if (change) {
115
+ expect(result.recovered).toBe(true);
116
+ expect(update).toContain('--memory=805306368');
117
+ expect(f.calls.indexOf(update)).toBeLessThan(
118
+ f.calls.findIndex((a) => a[0] === 'start')
119
+ );
120
+ expect(f.store.get(f.record.uuid).options.resolvedLimits.memory).toBe(
121
+ 805306368
122
+ );
123
+ }
124
+ if (exitCode === 1) {
125
+ expect(result.recovered).toBe(false);
126
+ }
127
+ } finally {
128
+ fs.rmSync(f.dir, { recursive: true, force: true });
129
+ }
130
+ });
131
+ }
132
+
133
+ it('a failed memory update prevents starting the old-limit command', async () => {
134
+ const f = fixture();
135
+ try {
136
+ const result = await resumeExecution(f.store, f.record.uuid, {
137
+ resourceOptions: { memory: '128m' },
138
+ runner: (cmd, args) =>
139
+ args[0] === 'update'
140
+ ? (f.calls.push(args),
141
+ { success: false, stderr: 'daemon rejected update' })
142
+ : f.runner(cmd, args),
143
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
144
+ startWatcher: () => true,
145
+ });
146
+ expect(result.success).toBe(false);
147
+ expect(f.calls.some((args) => args[0] === 'start')).toBe(false);
148
+ expect(f.store.get(f.record.uuid).status).toBe('executed');
149
+ } finally {
150
+ fs.rmSync(f.dir, { recursive: true, force: true });
151
+ }
152
+ });
@@ -0,0 +1,66 @@
1
+ const { it, expect } = require('bun:test');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const {
7
+ buildCgroupSamplerStartSnippet,
8
+ buildCgroupMemoryLogSnippet,
9
+ } = require('../src/lib/cgroup-memory');
10
+ it('samples remote/private cgroups through Docker and reports unavailable counters', () => {
11
+ expect(buildCgroupSamplerStartSnippet('remote-task')).toContain(
12
+ 'docker exec'
13
+ );
14
+ expect(buildCgroupMemoryLogSnippet("'/tmp/log'")).toContain('unavailable');
15
+ expect(buildCgroupSamplerStartSnippet('remote-task')).toContain(
16
+ 'HostConfig.Memory'
17
+ );
18
+ });
19
+
20
+ for (const mode of ['remote', 'no-shell', 'outage', 'shared']) {
21
+ it(`real watcher shell: ${mode}`, () => {
22
+ if (process.platform === 'win32') {
23
+ return;
24
+ }
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'remote-192-'));
26
+ try {
27
+ const script = path.join(dir, 'sample.sh');
28
+ fs.writeFileSync(
29
+ script,
30
+ [
31
+ buildCgroupSamplerStartSnippet('remote-task'),
32
+ 'sleep 1.2',
33
+ require('../src/lib/cgroup-memory').buildCgroupSamplerStopSnippet(),
34
+ buildCgroupMemoryLogSnippet('"$TMPDIR/memory.log"'),
35
+ 'cat "$TMPDIR/memory.log"',
36
+ ].join('; ')
37
+ );
38
+ const result = spawnSync(
39
+ '/bin/sh',
40
+ [
41
+ path.resolve(
42
+ __dirname,
43
+ '../../experiments/issue-195-memory-sampler.sh'
44
+ ),
45
+ mode,
46
+ script,
47
+ ],
48
+ { encoding: 'utf8' }
49
+ );
50
+ expect(result.status).toBe(0);
51
+ if (mode === 'remote' || mode === 'outage') {
52
+ expect(result.stdout).toContain(
53
+ 'memory.max=67108864 memory.peak=33554432 oom=1 oom_kill=3'
54
+ );
55
+ } else {
56
+ expect(result.stdout).toContain('Memory: unavailable');
57
+ expect(result.stdout).toContain('memory.limit=67108864 (HostConfig)');
58
+ expect(result.stdout).toContain(
59
+ mode === 'no-shell' ? 'sh: not found' : 'namespace=host'
60
+ );
61
+ }
62
+ } finally {
63
+ fs.rmSync(dir, { recursive: true, force: true });
64
+ }
65
+ }, 10000);
66
+ }
@@ -0,0 +1,241 @@
1
+ const { describe, it, expect } = require('bun:test');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { LockManager, ExecutionRecord } = require('../src/lib/execution-store');
6
+ const { resumeExecution } = require('../src/lib/execution-resume');
7
+ const { SessionState } = require('../src/lib/session-probe');
8
+
9
+ describe('issue #193: durable store locks and resume', () => {
10
+ for (const content of [
11
+ '',
12
+ '{"pid":',
13
+ JSON.stringify({
14
+ pid: String(process.pid),
15
+ timestamp: Date.now(),
16
+ hostname: os.hostname(),
17
+ }),
18
+ ]) {
19
+ it(`reclaims an old malformed lock (${JSON.stringify(content)})`, () => {
20
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lock-193-'));
21
+ const file = path.join(dir, 'executions.lock');
22
+ try {
23
+ fs.writeFileSync(file, content);
24
+ const old = new Date(Date.now() - 10000);
25
+ fs.utimesSync(file, old, old);
26
+ const lock = new LockManager(file);
27
+ expect(lock.acquire(300)).toBe(true);
28
+ lock.release();
29
+ expect(fs.existsSync(file)).toBe(false);
30
+ } finally {
31
+ fs.rmSync(dir, { recursive: true, force: true });
32
+ }
33
+ });
34
+ }
35
+
36
+ it('does not replace a fresh incomplete lock', () => {
37
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lock-193-'));
38
+ const file = path.join(dir, 'executions.lock');
39
+ try {
40
+ fs.writeFileSync(file, '');
41
+ expect(new LockManager(file).acquire(100)).toBe(false);
42
+ } finally {
43
+ fs.rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ it('reserves the replacement session before Docker side effects', async () => {
48
+ const record = new ExecutionRecord({
49
+ command: 'old',
50
+ status: 'executed',
51
+ exitCode: 137,
52
+ options: {
53
+ isolated: 'docker',
54
+ isolationMode: 'detached',
55
+ sessionName: 'task',
56
+ },
57
+ });
58
+ let saved;
59
+ const store = {
60
+ get: () => saved || record,
61
+ save: (r) => {
62
+ saved = globalThis.structuredClone(r);
63
+ },
64
+ };
65
+ const result = await resumeExecution(store, record.uuid, {
66
+ command: 'new',
67
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
68
+ runner: (_cmd, args) => {
69
+ if (args[0] !== 'inspect') {
70
+ expect(saved?.options.sessionName).toBe('task-resume-1');
71
+ expect(saved?.attempt.launchAcceptedAt).toBeNull();
72
+ }
73
+ return { success: true, stdout: args[0] === 'inspect' ? '{}' : 'id' };
74
+ },
75
+ startWatcher: () => true,
76
+ });
77
+ expect(result.success).toBe(true);
78
+ });
79
+
80
+ it('never launches when the durable reservation fails', async () => {
81
+ const record = new ExecutionRecord({
82
+ command: 'old',
83
+ status: 'executed',
84
+ options: {
85
+ isolated: 'docker',
86
+ isolationMode: 'detached',
87
+ sessionName: 'task',
88
+ },
89
+ });
90
+ const calls = [];
91
+ const result = await resumeExecution(
92
+ {
93
+ get: () => record,
94
+ save: () => {
95
+ throw new Error('ENOSPC');
96
+ },
97
+ },
98
+ record.uuid,
99
+ {
100
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
101
+ runner: (_cmd, args) => {
102
+ calls.push(args);
103
+ return { success: true, stdout: 'id' };
104
+ },
105
+ }
106
+ );
107
+ expect(result.success).toBe(false);
108
+ expect(calls.some((args) => args[0] === 'start')).toBe(false);
109
+ });
110
+ });
111
+
112
+ it('an ENOSPC while writing lock data never publishes an empty shared lock', () => {
113
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'enospc-193-'));
114
+ const file = path.join(dir, 'executions.lock');
115
+ try {
116
+ const failedFs = {
117
+ ...fs,
118
+ writeFileSync: (name) => {
119
+ fs.writeFileSync(name, '');
120
+ throw Object.assign(new Error('disk full'), { code: 'ENOSPC' });
121
+ },
122
+ };
123
+ expect(() => new LockManager(file, failedFs).acquire(100)).toThrow(
124
+ 'disk full'
125
+ );
126
+ expect(fs.readdirSync(dir)).toEqual([]);
127
+ const healthy = new LockManager(file);
128
+ expect(healthy.acquire(100)).toBe(true);
129
+ healthy.release();
130
+ } finally {
131
+ fs.rmSync(dir, { recursive: true, force: true });
132
+ }
133
+ });
134
+
135
+ for (const canStop of [true, false]) {
136
+ it(`attaches the watcher and identifies a postlaunch save failure (stop=${canStop})`, async () => {
137
+ const record = new ExecutionRecord({
138
+ command: 'work',
139
+ status: 'executed',
140
+ options: {
141
+ isolated: 'docker',
142
+ isolationMode: 'detached',
143
+ sessionName: 'task',
144
+ },
145
+ });
146
+ let saves = 0,
147
+ saved,
148
+ watched = false;
149
+ const calls = [];
150
+ const store = {
151
+ get: () => saved || record,
152
+ save: (r) => {
153
+ if (++saves > 1) {
154
+ throw Error('ENOSPC');
155
+ }
156
+ saved = globalThis.structuredClone(r);
157
+ },
158
+ };
159
+ const result = await resumeExecution(store, record.uuid, {
160
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
161
+ runner: (_, args) => {
162
+ calls.push(args);
163
+ return {
164
+ success: args[0] !== 'stop' || canStop,
165
+ stdout: args[0] === 'inspect' ? '{}' : 'id',
166
+ };
167
+ },
168
+ startWatcher: () => {
169
+ watched = true;
170
+ return true;
171
+ },
172
+ });
173
+ expect(result.success).toBe(false);
174
+ expect(watched).toBe(true);
175
+ expect(calls.some((args) => args[0] === 'stop')).toBe(true);
176
+ const error = JSON.parse(result.error);
177
+ expect(error.code).toBe('LAUNCH_PERSISTENCE_FAILED');
178
+ expect(error.containerName).toBe('task');
179
+ expect(error.running).toBe(!canStop);
180
+ expect(saved.options.launchPending).toBe(true);
181
+ });
182
+ }
183
+
184
+ it('a partial ENOSPC database write preserves the durable reservation', () => {
185
+ const { ExecutionStore } = require('../src/lib/execution-store');
186
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'database-193-'));
187
+ const store = new ExecutionStore({ appFolder: dir, useLinks: false });
188
+ const record = new ExecutionRecord({
189
+ command: 'reserved',
190
+ options: { launchPending: true, sessionName: 'task-resume-1' },
191
+ });
192
+ store.save(record);
193
+ const original = fs.writeFileSync;
194
+ try {
195
+ fs.writeFileSync = (name, content, ...args) => {
196
+ if (typeof name === 'string' && name.startsWith(store.linoDbPath)) {
197
+ original(name, String(content).slice(0, 8), ...args);
198
+ throw Object.assign(Error('disk full'), { code: 'ENOSPC' });
199
+ }
200
+ return original(name, content, ...args);
201
+ };
202
+ expect(() => store.save(new ExecutionRecord({ command: 'new' }))).toThrow(
203
+ 'disk full'
204
+ );
205
+ expect(store.get(record.uuid)?.options.sessionName).toBe('task-resume-1');
206
+ expect(fs.readdirSync(dir).filter((name) => name.includes('.tmp'))).toEqual(
207
+ []
208
+ );
209
+ } finally {
210
+ fs.writeFileSync = original;
211
+ fs.rmSync(dir, { recursive: true, force: true });
212
+ }
213
+ });
214
+
215
+ it('a dead launcher does not permanently block a later resume', async () => {
216
+ const { ExecutionStore } = require('../src/lib/execution-store');
217
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'owner-193-'));
218
+ try {
219
+ const store = new ExecutionStore({ appFolder: dir, useLinks: false });
220
+ const record = new ExecutionRecord({
221
+ command: 'work',
222
+ options: {
223
+ isolated: 'docker',
224
+ isolationMode: 'detached',
225
+ sessionName: 'task',
226
+ launchPending: true,
227
+ launchOwner: { pid: 2147483647, hostname: os.hostname() },
228
+ },
229
+ });
230
+ store.save(record);
231
+ const result = await resumeExecution(store, record.uuid, {
232
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
233
+ runner: () => ({ success: true, stdout: '{}' }),
234
+ startWatcher: () => true,
235
+ });
236
+ expect(result.success).toBe(true);
237
+ expect(store.get(record.uuid).options.launchPending).toBe(false);
238
+ } finally {
239
+ fs.rmSync(dir, { recursive: true, force: true });
240
+ }
241
+ });