start-command 0.35.4 → 0.37.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 (67) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +16 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +48 -7
  5. package/src/lib/args-parser-queries.js +14 -0
  6. package/src/lib/args-parser.js +32 -1
  7. package/src/lib/atomic-write.js +34 -0
  8. package/src/lib/attached-diagnostics.js +90 -0
  9. package/src/lib/cgroup-memory.js +43 -11
  10. package/src/lib/command-builder.js +43 -0
  11. package/src/lib/cpu-penalty-monitor.js +226 -0
  12. package/src/lib/cpu-penalty.js +175 -0
  13. package/src/lib/detached-finalize.js +16 -0
  14. package/src/lib/docker-cleanup.js +35 -7
  15. package/src/lib/docker-command-handoff.js +75 -0
  16. package/src/lib/docker-labels.js +33 -0
  17. package/src/lib/docker-resource-limits.js +28 -0
  18. package/src/lib/docker-resource-options.js +180 -0
  19. package/src/lib/docker-runtime-args.js +23 -0
  20. package/src/lib/docker-snapshot-safety.js +164 -0
  21. package/src/lib/execution-recovery.js +153 -30
  22. package/src/lib/execution-resume-all.js +11 -0
  23. package/src/lib/execution-resume.js +314 -54
  24. package/src/lib/execution-store.js +25 -121
  25. package/src/lib/exit-evidence.js +123 -0
  26. package/src/lib/exit-reason.js +24 -6
  27. package/src/lib/failure-handler.js +15 -5
  28. package/src/lib/isolation.js +48 -7
  29. package/src/lib/launch-owner.js +25 -0
  30. package/src/lib/launch-persistence.js +54 -0
  31. package/src/lib/log-sanitizer.js +267 -0
  32. package/src/lib/log-uploader.js +38 -19
  33. package/src/lib/query-commands.js +18 -6
  34. package/src/lib/resume-resources.js +88 -0
  35. package/src/lib/shell-utils.js +67 -17
  36. package/src/lib/status-formatter.js +28 -0
  37. package/src/lib/store-lock.js +133 -0
  38. package/src/lib/usage.js +16 -1
  39. package/test/ci-workflow-invariants.js +15 -14
  40. package/test/docker-command-handoff.js +107 -0
  41. package/test/docker-runtime-options.js +10 -0
  42. package/test/docker-snapshot-safety.js +106 -0
  43. package/test/execution-resume.js +19 -11
  44. package/test/exit-reason.js +6 -1
  45. package/test/failure-handler.js +11 -0
  46. package/test/isolation.js +5 -4
  47. package/test/javascript-first-ci.js +216 -0
  48. package/test/log-sanitizer.js +306 -0
  49. package/test/regression-174.js +3 -1
  50. package/test/regression-176.js +7 -0
  51. package/test/regression-180.js +17 -10
  52. package/test/regression-182.js +7 -3
  53. package/test/regression-185.js +4 -9
  54. package/test/regression-187.js +11 -2
  55. package/test/regression-189.js +224 -0
  56. package/test/regression-190.js +111 -0
  57. package/test/regression-191.js +160 -0
  58. package/test/regression-192.js +66 -0
  59. package/test/regression-193.js +251 -0
  60. package/test/regression-194.js +182 -0
  61. package/test/regression-198.js +281 -0
  62. package/test/regression-199.js +79 -0
  63. package/test/regression-201.js +25 -0
  64. package/test/regression-202.js +92 -0
  65. package/test/regression-91.js +23 -20
  66. package/test/shared-golden.js +54 -0
  67. package/test/status-query.js +97 -15
@@ -0,0 +1,251 @@
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
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
68
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
69
+ runner: (_cmd, args) => {
70
+ if (args[0] !== 'inspect') {
71
+ expect(saved?.options.sessionName).toBe('task-resume-1');
72
+ expect(saved?.attempt.launchAcceptedAt).toBeNull();
73
+ }
74
+ return {
75
+ success: true,
76
+ stdout: args.includes('--size')
77
+ ? '1024'
78
+ : args[0] === 'info'
79
+ ? JSON.stringify({ DockerRootDir: '/docker' })
80
+ : args[0] === 'inspect'
81
+ ? '{}'
82
+ : 'id',
83
+ };
84
+ },
85
+ startWatcher: () => true,
86
+ });
87
+ expect(result.success).toBe(true);
88
+ });
89
+
90
+ it('never launches when the durable reservation fails', async () => {
91
+ const record = new ExecutionRecord({
92
+ command: 'old',
93
+ status: 'executed',
94
+ options: {
95
+ isolated: 'docker',
96
+ isolationMode: 'detached',
97
+ sessionName: 'task',
98
+ },
99
+ });
100
+ const calls = [];
101
+ const result = await resumeExecution(
102
+ {
103
+ get: () => record,
104
+ save: () => {
105
+ throw new Error('ENOSPC');
106
+ },
107
+ },
108
+ record.uuid,
109
+ {
110
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
111
+ runner: (_cmd, args) => {
112
+ calls.push(args);
113
+ return { success: true, stdout: 'id' };
114
+ },
115
+ }
116
+ );
117
+ expect(result.success).toBe(false);
118
+ expect(calls.some((args) => args[0] === 'start')).toBe(false);
119
+ });
120
+ });
121
+
122
+ it('an ENOSPC while writing lock data never publishes an empty shared lock', () => {
123
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'enospc-193-'));
124
+ const file = path.join(dir, 'executions.lock');
125
+ try {
126
+ const failedFs = {
127
+ ...fs,
128
+ writeFileSync: (name) => {
129
+ fs.writeFileSync(name, '');
130
+ throw Object.assign(new Error('disk full'), { code: 'ENOSPC' });
131
+ },
132
+ };
133
+ expect(() => new LockManager(file, failedFs).acquire(100)).toThrow(
134
+ 'disk full'
135
+ );
136
+ expect(fs.readdirSync(dir)).toEqual([]);
137
+ const healthy = new LockManager(file);
138
+ expect(healthy.acquire(100)).toBe(true);
139
+ healthy.release();
140
+ } finally {
141
+ fs.rmSync(dir, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ for (const canStop of [true, false]) {
146
+ it(`attaches the watcher and identifies a postlaunch save failure (stop=${canStop})`, async () => {
147
+ const record = new ExecutionRecord({
148
+ command: 'work',
149
+ status: 'executed',
150
+ options: {
151
+ isolated: 'docker',
152
+ isolationMode: 'detached',
153
+ sessionName: 'task',
154
+ },
155
+ });
156
+ let saves = 0,
157
+ saved,
158
+ watched = false;
159
+ const calls = [];
160
+ const store = {
161
+ get: () => saved || record,
162
+ save: (r) => {
163
+ if (++saves > 1) {
164
+ throw Error('ENOSPC');
165
+ }
166
+ saved = globalThis.structuredClone(r);
167
+ },
168
+ };
169
+ const result = await resumeExecution(store, record.uuid, {
170
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
171
+ runner: (_, args) => {
172
+ calls.push(args);
173
+ return {
174
+ success: args[0] !== 'stop' || canStop,
175
+ stdout: args[0] === 'inspect' ? '{}' : 'id',
176
+ };
177
+ },
178
+ startWatcher: () => {
179
+ watched = true;
180
+ return true;
181
+ },
182
+ });
183
+ expect(result.success).toBe(false);
184
+ expect(watched).toBe(true);
185
+ expect(calls.some((args) => args[0] === 'stop')).toBe(true);
186
+ const error = JSON.parse(result.error);
187
+ expect(error.code).toBe('LAUNCH_PERSISTENCE_FAILED');
188
+ expect(error.containerName).toBe('task');
189
+ expect(error.running).toBe(!canStop);
190
+ expect(saved.options.launchPending).toBe(true);
191
+ });
192
+ }
193
+
194
+ it('a partial ENOSPC database write preserves the durable reservation', () => {
195
+ const { ExecutionStore } = require('../src/lib/execution-store');
196
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'database-193-'));
197
+ const store = new ExecutionStore({ appFolder: dir, useLinks: false });
198
+ const record = new ExecutionRecord({
199
+ command: 'reserved',
200
+ options: { launchPending: true, sessionName: 'task-resume-1' },
201
+ });
202
+ store.save(record);
203
+ const original = fs.writeFileSync;
204
+ try {
205
+ fs.writeFileSync = (name, content, ...args) => {
206
+ if (typeof name === 'string' && name.startsWith(store.linoDbPath)) {
207
+ original(name, String(content).slice(0, 8), ...args);
208
+ throw Object.assign(Error('disk full'), { code: 'ENOSPC' });
209
+ }
210
+ return original(name, content, ...args);
211
+ };
212
+ expect(() => store.save(new ExecutionRecord({ command: 'new' }))).toThrow(
213
+ 'disk full'
214
+ );
215
+ expect(store.get(record.uuid)?.options.sessionName).toBe('task-resume-1');
216
+ expect(fs.readdirSync(dir).filter((name) => name.includes('.tmp'))).toEqual(
217
+ []
218
+ );
219
+ } finally {
220
+ fs.writeFileSync = original;
221
+ fs.rmSync(dir, { recursive: true, force: true });
222
+ }
223
+ });
224
+
225
+ it('a dead launcher does not permanently block a later resume', async () => {
226
+ const { ExecutionStore } = require('../src/lib/execution-store');
227
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'owner-193-'));
228
+ try {
229
+ const store = new ExecutionStore({ appFolder: dir, useLinks: false });
230
+ const record = new ExecutionRecord({
231
+ command: 'work',
232
+ options: {
233
+ isolated: 'docker',
234
+ isolationMode: 'detached',
235
+ sessionName: 'task',
236
+ launchPending: true,
237
+ launchOwner: { pid: 2147483647, hostname: os.hostname() },
238
+ },
239
+ });
240
+ store.save(record);
241
+ const result = await resumeExecution(store, record.uuid, {
242
+ probe: () => ({ alive: false, state: SessionState.STOPPED }),
243
+ runner: () => ({ success: true, stdout: '{}' }),
244
+ startWatcher: () => true,
245
+ });
246
+ expect(result.success).toBe(true);
247
+ expect(store.get(record.uuid).options.launchPending).toBe(false);
248
+ } finally {
249
+ fs.rmSync(dir, { recursive: true, force: true });
250
+ }
251
+ });
@@ -0,0 +1,182 @@
1
+ const { it, expect } = require('bun:test');
2
+ const {
3
+ resolveExitReason,
4
+ resolveMemoryExhaustion,
5
+ } = require('../src/lib/exit-reason');
6
+ it('historical child OOM kills cannot explain a subsequent SIGKILL', () => {
7
+ const facts = {
8
+ exitCode: 137,
9
+ oomKilled: true,
10
+ cgroupMemory: { oomKills: 3 },
11
+ };
12
+ expect(resolveExitReason(facts)).toBe('signal (SIGKILL; cause unknown)');
13
+ expect(resolveMemoryExhaustion(facts)).toBeNull();
14
+ });
15
+
16
+ const evidence = require('../src/lib/exit-evidence');
17
+ it('requires a recent sampled delta and rejects historical or malformed evidence', () => {
18
+ const finished = '1970-01-01T00:01:40Z';
19
+ expect(evidence.recentOomDelta('64 63 3 2 100 100', finished)).toBe(true);
20
+ for (const sample of [
21
+ '64 63 3 2',
22
+ '64 63 3 2 0 100',
23
+ '64 63 3 2 95 100',
24
+ '64 63 3 2 101 100',
25
+ '64 63 3 2 nope 100',
26
+ ]) {
27
+ expect(evidence.recentOomDelta(sample, finished)).toBe(false);
28
+ }
29
+ const line = evidence.collectExitEvidence(
30
+ {
31
+ containerName: 'task',
32
+ sample: '64 63 3 2 100 100',
33
+ finishedAt: finished,
34
+ exitCode: 137,
35
+ oomKilled: true,
36
+ },
37
+ () => ({ success: false, stdout: '' })
38
+ );
39
+ expect(line).toBe(evidence.MAIN_OOM);
40
+ expect(
41
+ resolveExitReason({ exitCode: 137, oomKilled: true, logTail: line })
42
+ ).toBe('memory-exhaustion (cgroup-oom-killer)');
43
+ });
44
+ it('requires Docker service and matching-container force-kill evidence for a daemon restart', () => {
45
+ const id = 'a'.repeat(64);
46
+ const journal = `docker.service: Main process exited\nContainer failed to exit within 10s of signal 15 - using the force container=${id}`;
47
+ expect(evidence.daemonRestartEvidence(journal, id)).toBe(true);
48
+ expect(
49
+ evidence.daemonRestartEvidence(
50
+ 'many containers exited at the same second',
51
+ id
52
+ )
53
+ ).toBe(false);
54
+ expect(evidence.daemonRestartEvidence(journal, 'b'.repeat(64))).toBe(false);
55
+ for (const oomKilled of [true, false]) {
56
+ const line = evidence.collectExitEvidence(
57
+ {
58
+ containerName: 'task',
59
+ sample: '64 63 3 2 100 100',
60
+ finishedAt: '1970-01-01T00:01:40Z',
61
+ exitCode: 137,
62
+ oomKilled,
63
+ },
64
+ (command) => ({
65
+ success: true,
66
+ stdout: command === 'journalctl' ? journal : id,
67
+ })
68
+ );
69
+ expect(line).toBe(evidence.DAEMON_RESTART);
70
+ expect(resolveExitReason({ exitCode: 137, oomKilled, logTail: line })).toBe(
71
+ 'killed (docker daemon restart)'
72
+ );
73
+ expect(
74
+ resolveMemoryExhaustion({ exitCode: 137, oomKilled, logTail: line })
75
+ ).toBeNull();
76
+ }
77
+ });
78
+ it('persists terminal evidence without requiring a surviving log file', () => {
79
+ const fs = require('fs'),
80
+ path = require('path'),
81
+ os = require('os');
82
+ const {
83
+ ExecutionStore,
84
+ ExecutionRecord,
85
+ } = require('../src/lib/execution-store');
86
+ const { finalizeDetachedExecution } = require('../src/lib/detached-finalize');
87
+ const { enrichDetachedStatus } = require('../src/lib/status-formatter');
88
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evidence-194-'));
89
+ try {
90
+ const store = new ExecutionStore({ appFolder: dir, useLinks: false });
91
+ const record = new ExecutionRecord({
92
+ command: 'work',
93
+ options: { isolated: 'docker', isolationMode: 'detached' },
94
+ });
95
+ store.save(record);
96
+ finalizeDetachedExecution({
97
+ store,
98
+ executionId: record.uuid,
99
+ exitCode: 137,
100
+ oomKilled: true,
101
+ finishedAt: '1970-01-01T00:01:40Z',
102
+ running: false,
103
+ cgroupMemory: '64 63 3 2 100 100',
104
+ });
105
+ const current = store.get(record.uuid);
106
+ expect(current.options.exitEvidence.mainOom).toBe(true);
107
+ expect(enrichDetachedStatus(current).exitReason).toBe(
108
+ 'memory-exhaustion (cgroup-oom-killer)'
109
+ );
110
+ } finally {
111
+ fs.rmSync(dir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ it('keeps logged main-process evidence when no cgroup sample reaches the finalizer', () => {
116
+ const fs = require('fs'),
117
+ path = require('path'),
118
+ os = require('os');
119
+ const {
120
+ ExecutionStore,
121
+ ExecutionRecord,
122
+ } = require('../src/lib/execution-store');
123
+ const { finalizeDetachedExecution } = require('../src/lib/detached-finalize');
124
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'logged-evidence-194-'));
125
+ try {
126
+ const store = new ExecutionStore({ appFolder: dir, useLinks: false });
127
+ const record = new ExecutionRecord({
128
+ command: 'work',
129
+ options: { isolated: 'docker', isolationMode: 'detached' },
130
+ });
131
+ record.logPath = path.join(dir, 'task.log');
132
+ fs.writeFileSync(record.logPath, `${evidence.MAIN_OOM}\n`);
133
+ store.save(record);
134
+ finalizeDetachedExecution({
135
+ store,
136
+ executionId: record.uuid,
137
+ exitCode: 137,
138
+ oomKilled: true,
139
+ finishedAt: '1970-01-01T00:01:40Z',
140
+ running: false,
141
+ });
142
+ expect(store.get(record.uuid).options.exitEvidence.mainOom).toBe(true);
143
+ fs.unlinkSync(record.logPath);
144
+ expect(
145
+ require('../src/lib/status-formatter').enrichDetachedStatus(
146
+ store.get(record.uuid)
147
+ ).exitReason
148
+ ).toBe('memory-exhaustion (cgroup-oom-killer)');
149
+ } finally {
150
+ fs.rmSync(dir, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ it('attached reporting uses attributed evidence and keeps ordinary exits final', () => {
155
+ const fs = require('fs'),
156
+ path = require('path'),
157
+ os = require('os');
158
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'attached-evidence-194-'));
159
+ try {
160
+ const logPath = path.join(dir, 'task.log');
161
+ fs.writeFileSync(logPath, `${evidence.MAIN_OOM}\n`);
162
+ const message =
163
+ require('../src/lib/docker-cleanup').buildAttachedDockerKeptMessage({
164
+ containerName: 'task',
165
+ exitCode: 137,
166
+ oomKilled: true,
167
+ logPath,
168
+ });
169
+ expect(message).toContain('reports it was OOM-killed');
170
+ for (const exitCode of [0, 1]) {
171
+ expect(
172
+ resolveMemoryExhaustion({
173
+ exitCode,
174
+ oomKilled: true,
175
+ logTail: evidence.MAIN_OOM,
176
+ })
177
+ ).toBeNull();
178
+ }
179
+ } finally {
180
+ fs.rmSync(dir, { recursive: true, force: true });
181
+ }
182
+ });