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,180 @@
1
+ /** Launch and recovery resource specifications, resolved against the daemon. */
2
+ const { getDockerCommand } = require('./docker-cleanup');
3
+ const { normalizeResourceLimits } = require('./docker-resource-limits');
4
+ const FIELDS = {
5
+ '--memory': 'memory',
6
+ '--memory-swap': 'memorySwap',
7
+ '--cpus': 'cpus',
8
+ '--on-kill-resume-memory': 'onKillResumeMemory',
9
+ };
10
+
11
+ function parseResourceSpec(value, cpu = false) {
12
+ const text = String(value).trim().toLowerCase();
13
+ const percent = /^(\d+(?:\.\d+)?)%(?:-(\d+(?:\.\d+)?)%)?$/.exec(text);
14
+ if (percent) {
15
+ const min = Number(percent[1]),
16
+ max = Number(percent[2] || percent[1]);
17
+ if (min > 0 && max <= 100 && min <= max) {
18
+ return { min, max, percent: true };
19
+ }
20
+ } else {
21
+ const absolute = /^(\d+(?:\.\d+)?)(b|k|m|g|t|kb|mb|gb|tb)?$/.exec(text);
22
+ if (absolute && (!cpu || !absolute[2])) {
23
+ const power = ['', 'k', 'm', 'g', 't'].indexOf(
24
+ (absolute[2] || '').replace('b', '')
25
+ );
26
+ const amount =
27
+ Number(absolute[1]) * (cpu ? 1 : 1024 ** Math.max(0, power));
28
+ if (
29
+ Number.isFinite(amount) &&
30
+ amount > 0 &&
31
+ amount <= Number.MAX_SAFE_INTEGER
32
+ ) {
33
+ return { amount };
34
+ }
35
+ }
36
+ }
37
+ throw new Error(
38
+ `Invalid resource limit: "${value}". Expected a positive ${cpu ? 'CPU count' : 'Docker size'}, N% or MIN%-MAX% within (0, 100].`
39
+ );
40
+ }
41
+
42
+ function parse(args, index, options) {
43
+ const [flag, inline] = args[index].split('=', 2);
44
+ if (!FIELDS[flag]) {
45
+ return 0;
46
+ }
47
+ const value = inline ?? args[index + 1];
48
+ if (!value || value.startsWith('-')) {
49
+ throw new Error(`Option ${flag} requires a resource limit`);
50
+ }
51
+ parseResourceSpec(value, flag === '--cpus');
52
+ options[FIELDS[flag]] = value;
53
+ return inline === undefined ? 2 : 1;
54
+ }
55
+
56
+ function validate(options) {
57
+ if (
58
+ !Object.values(FIELDS).some(
59
+ (field) => options[field] !== null && options[field] !== undefined
60
+ )
61
+ ) {
62
+ return;
63
+ }
64
+ if (
65
+ !options.resume &&
66
+ !(options.isolatedStack || [options.isolated]).includes('docker')
67
+ ) {
68
+ throw new Error('Resource options require Docker isolation or --resume');
69
+ }
70
+ if (options.memorySwap && !options.memory && !options.resume) {
71
+ throw new Error('--memory-swap requires --memory');
72
+ }
73
+ if (
74
+ options.onKillResumeMemory &&
75
+ !options.onKillResume &&
76
+ !options.recoveryCommand
77
+ ) {
78
+ throw new Error(
79
+ '--on-kill-resume-memory requires --on-kill-resume or --recovery-command'
80
+ );
81
+ }
82
+ }
83
+
84
+ function readDaemonCapacity(runner) {
85
+ const result = runner(getDockerCommand(), ['info', '--format', '{{json .}}']);
86
+ if (!result?.success) {
87
+ throw new Error('Cannot read resource capacity from Docker daemon');
88
+ }
89
+ const info = JSON.parse(result.stdout);
90
+ if (!(info.MemTotal > 0) || !(info.NCPU > 0)) {
91
+ throw new Error('Docker daemon did not report MemTotal/NCPU');
92
+ }
93
+ return { memory: info.MemTotal, cpus: info.NCPU };
94
+ }
95
+
96
+ function resolveResourceOptions(options, runner, random = Math.random) {
97
+ const specs = {};
98
+ for (const field of ['memory', 'memorySwap', 'cpus']) {
99
+ if (options[field] !== null && options[field] !== undefined) {
100
+ specs[field] = parseResourceSpec(options[field], field === 'cpus');
101
+ }
102
+ }
103
+ const capacity = Object.values(specs).some((s) => s.percent)
104
+ ? readDaemonCapacity(runner)
105
+ : null;
106
+ const resolved = {};
107
+ const limits = normalizeResourceLimits(options.resourceLimits);
108
+ for (const [field, spec] of Object.entries(specs)) {
109
+ const amount = spec.percent
110
+ ? (capacity[field === 'cpus' ? 'cpus' : 'memory'] *
111
+ (spec.min + (spec.max - spec.min) * random())) /
112
+ 100
113
+ : spec.amount;
114
+ resolved[field] =
115
+ field === 'cpus' ? Math.floor(amount * 1e9) / 1e9 : Math.floor(amount);
116
+ if (resolved[field] < (field === 'cpus' ? 0.01 : 6 * 1024 * 1024)) {
117
+ throw new Error(`Resolved ${field} is below Docker's minimum`);
118
+ }
119
+ }
120
+ if (
121
+ resolved.memory !== null &&
122
+ resolved.memory !== undefined &&
123
+ (resolved.memorySwap === null || resolved.memorySwap === undefined)
124
+ ) {
125
+ resolved.memorySwap = resolved.memory;
126
+ }
127
+ const existingMemory = limits
128
+ .find((flag) => flag.startsWith('--memory='))
129
+ ?.slice(9);
130
+ const memory =
131
+ resolved.memory ??
132
+ (existingMemory ? parseResourceSpec(existingMemory).amount : null);
133
+ if (
134
+ resolved.memorySwap !== null &&
135
+ resolved.memorySwap !== undefined &&
136
+ (memory === null || memory === undefined || resolved.memorySwap < memory)
137
+ ) {
138
+ throw new Error('memory-swap must be at least memory');
139
+ }
140
+ for (const [field, amount] of Object.entries(resolved)) {
141
+ const flag = field === 'memorySwap' ? '--memory-swap' : `--${field}`;
142
+ for (let i = limits.length - 1; i >= 0; i--) {
143
+ if (
144
+ limits[i].startsWith(`${flag}=`) ||
145
+ (field === 'cpus' && /^--cpu-(quota|period)=/.test(limits[i]))
146
+ ) {
147
+ limits.splice(i, 1);
148
+ }
149
+ }
150
+ limits.push(`${flag}=${amount}`);
151
+ }
152
+ return {
153
+ resourceLimits: limits,
154
+ resolvedLimits: resolved,
155
+ resourceLimitSpecs: Object.fromEntries(
156
+ Object.keys(specs).map((field) => [field, options[field]])
157
+ ),
158
+ };
159
+ }
160
+
161
+ function limitsLogLine(options) {
162
+ const values = options.resolvedLimits || {};
163
+ return Object.keys(values).length
164
+ ? `Limits: ${Object.entries(values)
165
+ .map(
166
+ ([field, value]) =>
167
+ `${field}=${value}${options.resourceLimitSpecs?.[field] ? ` (${options.resourceLimitSpecs[field]} -> ${value})` : ''}`
168
+ )
169
+ .join(' ')}`
170
+ : null;
171
+ }
172
+
173
+ module.exports = {
174
+ parse,
175
+ validate,
176
+ parseResourceSpec,
177
+ readDaemonCapacity,
178
+ resolveResourceOptions,
179
+ limitsLogLine,
180
+ };
@@ -42,6 +42,9 @@ function buildDockerRuntimeArgs(options = {}) {
42
42
  for (const envVar of options.env || []) {
43
43
  args.push('-e', envVar);
44
44
  }
45
+ for (const label of require('./docker-labels').dockerLabels(options)) {
46
+ args.push('--label', label);
47
+ }
45
48
  for (const volume of options.volumes || []) {
46
49
  args.push('-v', volume);
47
50
  }
@@ -69,6 +72,9 @@ function buildDockerRuntimeArgs(options = {}) {
69
72
  */
70
73
  function buildDockerRuntimeStatusLines(options = {}) {
71
74
  const lines = [];
75
+ if (options.labels && options.labels.length > 0) {
76
+ lines.push(`[Isolation] Labels: ${options.labels.join(', ')}`);
77
+ }
72
78
  if (options.volumes && options.volumes.length > 0) {
73
79
  lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
74
80
  }
@@ -97,6 +103,12 @@ function buildDockerRuntimeStatusLines(options = {}) {
97
103
  if (limitsLine) {
98
104
  lines.push(limitsLine);
99
105
  }
106
+ const limitsSummary = require('./docker-resource-options').limitsLogLine(
107
+ options
108
+ );
109
+ if (limitsSummary) {
110
+ lines.push(limitsSummary);
111
+ }
100
112
  const onKillResume = getOnKillResume(options);
101
113
  if (onKillResume) {
102
114
  const what = options.recoveryCommand || 'the original command';
@@ -123,6 +135,8 @@ function buildDockerRuntimeMetadata(options = {}) {
123
135
  options.volumes && options.volumes.length > 0 ? options.volumes : null,
124
136
  mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
125
137
  env: options.env && options.env.length > 0 ? options.env : null,
138
+ labels: options.labels && options.labels.length > 0 ? options.labels : null,
139
+ commandHandoff: options.detached ? true : null,
126
140
  privileged: options.privileged || null,
127
141
  network: networks[0] || null,
128
142
  networks: networks.length > 0 ? networks : null,
@@ -131,6 +145,15 @@ function buildDockerRuntimeMetadata(options = {}) {
131
145
  ? options.networkAliases
132
146
  : null,
133
147
  resourceLimits: resourceLimits.length > 0 ? resourceLimits : null,
148
+ resolvedLimits: options.resolvedLimits || null,
149
+ resourceLimitSpecs: options.resourceLimitSpecs || null,
150
+ onKillResumeMemory: options.onKillResumeMemory || null,
151
+ ...(options.cpuPenaltyConfig
152
+ ? {
153
+ cpuPenaltyConfig: options.cpuPenaltyConfig,
154
+ baseResourceLimits: resourceLimits,
155
+ }
156
+ : {}),
134
157
  onKillResume: getOnKillResume(options),
135
158
  recoveryCommand: options.recoveryCommand || null,
136
159
  onKillResumeDelay: getOnKillResume(options)
@@ -0,0 +1,164 @@
1
+ /** Serialize costly snapshot resumes and check every image-store filesystem. */
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { LockManager } = require('./store-lock');
6
+
7
+ const GiB = 1024 ** 3;
8
+ const SNAPSHOT_RESERVE_BYTES = 10 * GiB;
9
+ const SNAPSHOT_IMAGE_LABEL = 'start-command.snapshot-image';
10
+
11
+ function acquireSnapshotLock(options = {}) {
12
+ const lockPath =
13
+ options.lockPath ||
14
+ process.env.START_SNAPSHOT_LOCK ||
15
+ path.join(
16
+ process.platform === 'win32' ? os.tmpdir() : '/tmp',
17
+ 'start-command-docker-snapshot.lock'
18
+ );
19
+ const lock = new LockManager(lockPath);
20
+ // The ordinary store lock is held only for a brief write. A snapshot can
21
+ // legitimately take hours, so never reclaim this lock merely for its age.
22
+ lock.isLockStale = (data) => {
23
+ if (
24
+ !data ||
25
+ data.hostname !== os.hostname() ||
26
+ !Number.isInteger(data.pid) ||
27
+ data.pid <= 0
28
+ ) {
29
+ return false;
30
+ }
31
+ try {
32
+ process.kill(data.pid, 0);
33
+ return false;
34
+ } catch (error) {
35
+ return error.code === 'ESRCH';
36
+ }
37
+ };
38
+ if (!lock.acquire(1000)) {
39
+ throw new Error(
40
+ 'A Docker snapshot resume is in progress on this host. Retry after it finishes.'
41
+ );
42
+ }
43
+ return lock;
44
+ }
45
+
46
+ function containerdRoot(config = '') {
47
+ // Only the root-level TOML key configures containerd's storage root.
48
+ const topLevel = config.split(/^\s*\[/m)[0];
49
+ return (
50
+ topLevel.match(/^\s*root\s*=\s*["']([^"']+)["']/m)?.[1] ||
51
+ '/var/lib/containerd'
52
+ );
53
+ }
54
+
55
+ function defaultContainerdRoot() {
56
+ if (process.env.START_CONTAINERD_ROOT) {
57
+ return process.env.START_CONTAINERD_ROOT;
58
+ }
59
+ try {
60
+ return containerdRoot(
61
+ fs.readFileSync('/etc/containerd/config.toml', 'utf8')
62
+ );
63
+ } catch (error) {
64
+ if (error.code === 'ENOENT') {
65
+ return containerdRoot();
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ function readFreeBytes(root) {
72
+ const stats = fs.statfsSync(root, { bigint: true });
73
+ return Number(stats.bavail * stats.bsize);
74
+ }
75
+
76
+ function preflightSnapshot(containerName, runner, options = {}) {
77
+ const docker = require('./docker-cleanup').getDockerCommand();
78
+ const sizeResult = runner(docker, [
79
+ 'inspect',
80
+ '--size',
81
+ '--format',
82
+ '{{.SizeRw}}',
83
+ containerName,
84
+ ]);
85
+ const size = Number(String(sizeResult.stdout || '').trim());
86
+ if (
87
+ !sizeResult.success ||
88
+ !/^\d+$/.test(String(sizeResult.stdout || '').trim()) ||
89
+ !Number.isSafeInteger(size) ||
90
+ size < 0
91
+ ) {
92
+ throw new Error(
93
+ 'Cannot determine the writable layer size; refusing Docker snapshot resume.'
94
+ );
95
+ }
96
+ const infoResult = runner(docker, ['info', '--format', '{{json .}}']);
97
+ let info;
98
+ try {
99
+ info = JSON.parse(infoResult.stdout);
100
+ } catch {
101
+ /* Refuse unknown storage. */
102
+ }
103
+ if (!infoResult.success || !info?.DockerRootDir) {
104
+ throw new Error(
105
+ 'Cannot determine Docker data root; refusing Docker snapshot resume.'
106
+ );
107
+ }
108
+ const roots = [info.DockerRootDir];
109
+ if (
110
+ JSON.stringify(info.DriverStatus || []).includes(
111
+ 'io.containerd.snapshotter'
112
+ )
113
+ ) {
114
+ roots.push(options.containerdRoot || defaultContainerdRoot());
115
+ }
116
+ const required = 2 * size + (options.reserveBytes ?? SNAPSHOT_RESERVE_BYTES);
117
+ const free = options.freeBytes || readFreeBytes;
118
+ for (const root of [...new Set(roots)]) {
119
+ let available;
120
+ try {
121
+ available = free(root);
122
+ } catch (error) {
123
+ throw new Error(
124
+ `Cannot check free disk at ${root}: ${error.message}; refusing Docker snapshot resume.`,
125
+ { cause: error }
126
+ );
127
+ }
128
+ if (!Number.isFinite(available) || available < required) {
129
+ throw new Error(
130
+ `Insufficient disk at ${root}: need ${(required / GiB).toFixed(2)} GiB (2 × writable layer plus reserve), available ${(available / GiB).toFixed(2)} GiB. Docker snapshot resume refused.`
131
+ );
132
+ }
133
+ }
134
+ return {
135
+ size,
136
+ required,
137
+ roots,
138
+ message: `[Resume] snapshotting ${(size / GiB).toFixed(2)} GiB; disk preflight passed (${(required / GiB).toFixed(2)} GiB required).`,
139
+ };
140
+ }
141
+
142
+ function snapshotImageCleanupSnippet(
143
+ containerName,
144
+ redirection = '>/dev/null 2>&1'
145
+ ) {
146
+ const { shellQuote } = require('./isolation-log-utils');
147
+ const name = shellQuote(containerName);
148
+ const format = shellQuote(
149
+ `{{index .Config.Labels "${SNAPSHOT_IMAGE_LABEL}"}}`
150
+ );
151
+ return {
152
+ capture: `__start_snapshot_image=$(docker inspect -f ${format} ${name} 2>/dev/null || true)`,
153
+ remove: `case "$__start_snapshot_image" in start-command-resume/*) docker rmi "$__start_snapshot_image" ${redirection} || true;; esac`,
154
+ };
155
+ }
156
+
157
+ module.exports = {
158
+ acquireSnapshotLock,
159
+ containerdRoot,
160
+ preflightSnapshot,
161
+ SNAPSHOT_RESERVE_BYTES,
162
+ SNAPSHOT_IMAGE_LABEL,
163
+ snapshotImageCleanupSnippet,
164
+ };
@@ -39,6 +39,11 @@ const {
39
39
  readDockerResourceLimits,
40
40
  } = require('./docker-resource-limits');
41
41
  const { shellQuote } = require('./isolation-log-utils');
42
+ const {
43
+ reserveLaunch,
44
+ rollbackLaunch,
45
+ failLaunchedPersistence,
46
+ } = require('./launch-persistence');
42
47
  const {
43
48
  CGROUP_SHELL_VARS,
44
49
  formatCgroupMemoryLogLine,
@@ -299,7 +304,51 @@ function recoverKilledExecution(params = {}) {
299
304
  // Docker keeps `docker update` limits in the HostConfig across restarts;
300
305
  // they are read back so the log and `--status` show what the resumed run
301
306
  // is held to, and so a later relaunch can re-apply them.
302
- const resourceLimits = readDockerResourceLimits(containerName, runner);
307
+ let resourceLimits =
308
+ readDockerResourceLimits(containerName, runner) ||
309
+ opts.resourceLimits ||
310
+ [];
311
+ resourceLimits = require('./resume-resources').restoreBaseCpu(
312
+ resourceLimits,
313
+ opts
314
+ );
315
+ if (opts.cpuPenaltyConfig) {
316
+ try {
317
+ resourceLimits = require('./resume-resources').clampCpuToDaemon(
318
+ resourceLimits,
319
+ runner
320
+ );
321
+ } catch (error) {
322
+ return giveUp(error.message);
323
+ }
324
+ }
325
+ const evidence = require('./exit-evidence');
326
+ const terminalOom =
327
+ (params.oomKilled === true || params.oomKilled === 'true') &&
328
+ evidence.recentOomDelta(params.cgroupMemory, params.finishedAt) &&
329
+ !evidence.fromLog(
330
+ record.logPath
331
+ ? require('./execution-attempt').readAttemptLogTail(record, 65536)
332
+ : null
333
+ ).daemonRestart;
334
+ let recoveryLimits = null;
335
+ if (opts.onKillResumeMemory && terminalOom) {
336
+ try {
337
+ recoveryLimits =
338
+ require('./docker-resource-options').resolveResourceOptions(
339
+ { memory: opts.onKillResumeMemory, resourceLimits },
340
+ runner,
341
+ params.random
342
+ );
343
+ } catch (error) {
344
+ return giveUp(`recovery limit failed: ${error.message}`);
345
+ }
346
+ log(
347
+ `Recovery memory limit: ${resourceLimits.find((s) => s.startsWith('--memory=')) || 'unlimited'} -> ${recoveryLimits.resolvedLimits.memory} (${opts.onKillResumeMemory})\n`
348
+ );
349
+ resourceLimits = recoveryLimits.resourceLimits;
350
+ }
351
+
303
352
  const limitsLine = buildResourceLimitsStatusLine(resourceLimits);
304
353
  if (limitsLine) {
305
354
  log(`${limitsLine}\n`);
@@ -308,7 +357,14 @@ function recoverKilledExecution(params = {}) {
308
357
  if (opts.recoveryCommand) {
309
358
  let copied;
310
359
  try {
311
- copied = writeRecoveryMarker(containerName, attempt, runner);
360
+ copied = opts.commandHandoff
361
+ ? require('./docker-command-handoff').writeCommandHandoff(
362
+ containerName,
363
+ `export ${RECOVERY_ATTEMPT_ENV}=${attempt}; ${opts.recoveryCommand}`,
364
+ runner,
365
+ opts
366
+ )
367
+ : writeRecoveryMarker(containerName, attempt, runner);
312
368
  } catch (err) {
313
369
  copied = { success: false, error: err.message };
314
370
  }
@@ -337,18 +393,6 @@ function recoverKilledExecution(params = {}) {
337
393
  'resume-started'
338
394
  );
339
395
  }
340
- const started = runner(getDockerCommand(), ['start', containerName]);
341
- if (!started.success) {
342
- if (nextAttempt) {
343
- appendLifecycle(
344
- { uuid: record.uuid, logPath: record.logPath, attempt: nextAttempt },
345
- 'launch-failed',
346
- { error: failureDetail(started) }
347
- );
348
- }
349
- return giveUp(`docker start failed: ${failureDetail(started)}`);
350
- }
351
-
352
396
  const described = describeExitCode(params.exitCode);
353
397
  const delayed = delayRange ? `, delayMs=${delayMs}` : '';
354
398
  // The killed run's own counters: the resumed run starts a fresh cgroup.
@@ -363,7 +407,7 @@ function recoverKilledExecution(params = {}) {
363
407
  cgroupMemory: memory || undefined,
364
408
  });
365
409
  archiveAttempt(record);
366
- nextAttempt.launchAcceptedAt = new Date().toISOString();
410
+
367
411
  record.attempt = nextAttempt;
368
412
  }
369
413
  const counted = memory
@@ -374,6 +418,18 @@ function recoverKilledExecution(params = {}) {
374
418
  : '';
375
419
  record.options = {
376
420
  ...opts,
421
+ ...(recoveryLimits
422
+ ? {
423
+ resolvedLimits: {
424
+ ...opts.resolvedLimits,
425
+ ...recoveryLimits.resolvedLimits,
426
+ },
427
+ resourceLimitSpecs: {
428
+ ...opts.resourceLimitSpecs,
429
+ memory: opts.onKillResumeMemory,
430
+ },
431
+ }
432
+ : {}),
377
433
  recoveryAttempts: attempt,
378
434
  recoveryHistory: [
379
435
  ...(Array.isArray(opts.recoveryHistory) ? opts.recoveryHistory : []),
@@ -383,6 +439,7 @@ function recoverKilledExecution(params = {}) {
383
439
  ...(delayRange ? { lastRecoveryDelayMs: delayMs } : {}),
384
440
  ...(resourceLimits && resourceLimits.length > 0 ? { resourceLimits } : {}),
385
441
  };
442
+ delete record.options.exitEvidence;
386
443
  record.status = 'executing';
387
444
  record.exitCode = null;
388
445
  record.endTime = null;
@@ -395,11 +452,56 @@ function recoverKilledExecution(params = {}) {
395
452
  record.observedAt = undefined;
396
453
  record.staleDetectedAt = undefined;
397
454
  record.containerStartedAt = undefined;
455
+ const previousData = globalThis.structuredClone(store.get(executionId));
456
+ const previous = record.constructor.fromObject
457
+ ? record.constructor.fromObject(previousData)
458
+ : previousData;
459
+ try {
460
+ reserveLaunch(store, record, previous);
461
+ } catch (error) {
462
+ return giveUp(`launch reservation failed: ${error.message}`);
463
+ }
464
+ if (recoveryLimits || opts.cpuPenaltyConfig) {
465
+ const updated = runner(getDockerCommand(), [
466
+ 'update',
467
+ ...resourceLimits.filter((s) =>
468
+ /^--(?:memory|memory-swap|cpus|cpu-quota|cpu-period)=/.test(s)
469
+ ),
470
+ containerName,
471
+ ]);
472
+ if (!updated.success) {
473
+ rollbackLaunch(store, previous);
474
+ return giveUp(`docker update failed: ${failureDetail(updated)}`);
475
+ }
476
+ }
477
+ const started = runner(getDockerCommand(), ['start', containerName]);
478
+ if (!started.success) {
479
+ if (nextAttempt) {
480
+ appendLifecycle(
481
+ { uuid: record.uuid, logPath: record.logPath, attempt: nextAttempt },
482
+ 'launch-failed',
483
+ { error: failureDetail(started) }
484
+ );
485
+ }
486
+ rollbackLaunch(store, previous);
487
+ return giveUp(`docker start failed: ${failureDetail(started)}`);
488
+ }
489
+
490
+ if (opts.cpuPenaltyConfig) {
491
+ record.options.resolvedLimits = {
492
+ ...record.options.resolvedLimits,
493
+ cpus: require('./docker-resource-limits').cpuCount(resourceLimits),
494
+ };
495
+ }
496
+ record.options.launchPending = false;
497
+ if (nextAttempt) {
498
+ nextAttempt.launchAcceptedAt = new Date().toISOString();
499
+ }
500
+ let persistenceError = null;
398
501
  try {
399
502
  store.save(record);
400
- } catch {
401
- // The container is already running again; the new watcher still follows
402
- // it and finalizes the record when it ends.
503
+ } catch (error) {
504
+ persistenceError = error;
403
505
  }
404
506
 
405
507
  const startWatcher =
@@ -407,18 +509,29 @@ function recoverKilledExecution(params = {}) {
407
509
  if (nextAttempt) {
408
510
  appendLifecycle(record, 'launch-accepted');
409
511
  }
410
- startWatcher(
411
- containerName,
412
- getDockerContainerCleanupPolicy(opts),
413
- record.logPath || null,
414
- record.uuid,
415
- {
416
- since,
417
- recoverOnKill: true,
418
- ...(nextAttempt ? { attemptNumber: nextAttempt.number } : {}),
419
- }
420
- );
421
- if (nextAttempt) {
512
+ try {
513
+ startWatcher(
514
+ containerName,
515
+ getDockerContainerCleanupPolicy(opts),
516
+ record.logPath || null,
517
+ record.uuid,
518
+ {
519
+ since,
520
+ recoverOnKill: true,
521
+ ...(nextAttempt ? { attemptNumber: nextAttempt.number } : {}),
522
+ }
523
+ );
524
+ } catch (error) {
525
+ const warning = failLaunchedPersistence(
526
+ store,
527
+ record,
528
+ runner,
529
+ persistenceError || error
530
+ );
531
+ log(`[Recovery] ${warning}\n`);
532
+ return { recovered: false, reason: warning, attempt };
533
+ }
534
+ if (nextAttempt && !persistenceError) {
422
535
  const current = patchAttempt(store, record, {
423
536
  watcherAttachedAt: new Date().toISOString(),
424
537
  });
@@ -426,6 +539,16 @@ function recoverKilledExecution(params = {}) {
426
539
  appendLifecycle(current, 'watcher-attached');
427
540
  }
428
541
  }
542
+ if (persistenceError) {
543
+ const warning = failLaunchedPersistence(
544
+ store,
545
+ record,
546
+ runner,
547
+ persistenceError
548
+ );
549
+ log(`[Recovery] ${warning}\n`);
550
+ return { recovered: false, reason: warning, attempt };
551
+ }
429
552
  return {
430
553
  recovered: true,
431
554
  reason: 'resumed',
@@ -125,6 +125,17 @@ function resumeAllExecutions(store, deps = {}) {
125
125
  for (const record of records) {
126
126
  const probe = probeFn(record);
127
127
  const sessionName = probe.sessionName;
128
+ if (require('./launch-owner').hasActiveLaunch(record)) {
129
+ executions.push(
130
+ describeRecord(
131
+ record,
132
+ probe,
133
+ ResumeAllAction.RUNNING,
134
+ 'Launch is reserved; no watcher or duplicate launch was started.'
135
+ )
136
+ );
137
+ continue;
138
+ }
128
139
 
129
140
  if (probe.alive && probe.backend === 'docker') {
130
141
  startWatcher(