start-command 0.35.3 → 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 (49) hide show
  1. package/CHANGELOG.md +12 -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 +47 -7
  13. package/src/lib/detached-output.js +61 -0
  14. package/src/lib/docker-cleanup.js +43 -4
  15. package/src/lib/docker-resource-limits.js +28 -0
  16. package/src/lib/docker-resource-options.js +180 -0
  17. package/src/lib/docker-runtime-args.js +15 -0
  18. package/src/lib/execution-attempt.js +153 -0
  19. package/src/lib/execution-recovery.js +191 -15
  20. package/src/lib/execution-resume-all.js +23 -1
  21. package/src/lib/execution-resume.js +203 -40
  22. package/src/lib/execution-store.js +56 -121
  23. package/src/lib/exit-evidence.js +123 -0
  24. package/src/lib/exit-reason.js +24 -6
  25. package/src/lib/isolation-log-utils.js +3 -3
  26. package/src/lib/isolation.js +36 -9
  27. package/src/lib/launch-owner.js +25 -0
  28. package/src/lib/launch-persistence.js +54 -0
  29. package/src/lib/query-commands.js +14 -3
  30. package/src/lib/resume-resources.js +88 -0
  31. package/src/lib/status-formatter.js +66 -6
  32. package/src/lib/store-lock.js +133 -0
  33. package/src/lib/usage.js +11 -0
  34. package/test/docker-runtime-options.js +6 -0
  35. package/test/execution-resume.js +10 -3
  36. package/test/exit-reason.js +6 -1
  37. package/test/isolation.js +5 -4
  38. package/test/regression-174.js +3 -1
  39. package/test/regression-176.js +5 -1
  40. package/test/regression-180.js +17 -10
  41. package/test/regression-182.js +7 -3
  42. package/test/regression-185.js +4 -9
  43. package/test/regression-187.js +547 -0
  44. package/test/regression-189.js +224 -0
  45. package/test/regression-190.js +111 -0
  46. package/test/regression-191.js +152 -0
  47. package/test/regression-192.js +66 -0
  48. package/test/regression-193.js +241 -0
  49. package/test/regression-194.js +182 -0
@@ -97,6 +97,12 @@ function buildDockerRuntimeStatusLines(options = {}) {
97
97
  if (limitsLine) {
98
98
  lines.push(limitsLine);
99
99
  }
100
+ const limitsSummary = require('./docker-resource-options').limitsLogLine(
101
+ options
102
+ );
103
+ if (limitsSummary) {
104
+ lines.push(limitsSummary);
105
+ }
100
106
  const onKillResume = getOnKillResume(options);
101
107
  if (onKillResume) {
102
108
  const what = options.recoveryCommand || 'the original command';
@@ -131,6 +137,15 @@ function buildDockerRuntimeMetadata(options = {}) {
131
137
  ? options.networkAliases
132
138
  : null,
133
139
  resourceLimits: resourceLimits.length > 0 ? resourceLimits : null,
140
+ resolvedLimits: options.resolvedLimits || null,
141
+ resourceLimitSpecs: options.resourceLimitSpecs || null,
142
+ onKillResumeMemory: options.onKillResumeMemory || null,
143
+ ...(options.cpuPenaltyConfig
144
+ ? {
145
+ cpuPenaltyConfig: options.cpuPenaltyConfig,
146
+ baseResourceLimits: resourceLimits,
147
+ }
148
+ : {}),
134
149
  onKillResume: getOnKillResume(options),
135
150
  recoveryCommand: options.recoveryCommand || null,
136
151
  onKillResumeDelay: getOnKillResume(options)
@@ -0,0 +1,153 @@
1
+ /** Attempt-scoped evidence for logical executions sharing a UUID and log. */
2
+ const fs = require('fs');
3
+ const { appendLogFile, readLogTail } = require('./isolation-log-utils');
4
+
5
+ const TERMINAL_FIELDS = [
6
+ 'endTimeSource',
7
+ 'observedAt',
8
+ 'staleDetectedAt',
9
+ 'containerStartedAt',
10
+ 'exitReason',
11
+ 'oomKilled',
12
+ 'memoryExhausted',
13
+ 'memoryExhaustedReason',
14
+ 'cgroupMemory',
15
+ ];
16
+
17
+ function activityPath(record) {
18
+ return `${record.logPath}.attempt-${record.attempt.number}.activity`;
19
+ }
20
+
21
+ function readAttemptActivity(record) {
22
+ if (!record.attempt || !record.logPath) {
23
+ return record;
24
+ }
25
+ let fd;
26
+ try {
27
+ fd = fs.openSync(activityPath(record), 'r');
28
+ const buffer = Buffer.alloc(128);
29
+ const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
30
+ const at = buffer.subarray(0, length).toString().trim();
31
+ if (
32
+ Number.isFinite(Date.parse(at)) &&
33
+ Date.parse(at) >= Date.parse(record.attempt.startedAt)
34
+ ) {
35
+ record.attempt = { ...record.attempt, lastOutputAt: at };
36
+ }
37
+ } catch {
38
+ // Quiet, missing, or unreadable output is not evidence of task progress.
39
+ } finally {
40
+ if (fd !== undefined) {
41
+ fs.closeSync(fd);
42
+ }
43
+ }
44
+ return record;
45
+ }
46
+
47
+ function createAttempt(record, plan) {
48
+ let logOffset = null;
49
+ if (record.logPath) {
50
+ try {
51
+ logOffset = fs.statSync(record.logPath).size;
52
+ } catch (error) {
53
+ if (error.code === 'ENOENT') {
54
+ logOffset = 0;
55
+ }
56
+ }
57
+ }
58
+ return {
59
+ number:
60
+ (record.attempt?.number ||
61
+ (Number(record.options?.resumeCount) || 0) +
62
+ (Number(record.options?.recoveryAttempts) || 0) +
63
+ 1) + 1,
64
+ startedAt: new Date().toISOString(),
65
+ logOffset,
66
+ lastOutputAt: null,
67
+ launchAcceptedAt: null,
68
+ watcherAttachedAt: null,
69
+ mode: plan.mode,
70
+ previousSessionName: record.options?.sessionName || null,
71
+ sessionName: plan.newSessionName || plan.sessionName,
72
+ };
73
+ }
74
+
75
+ function archiveAttempt(record) {
76
+ readAttemptActivity(record);
77
+ const snapshot = {
78
+ ...(record.attempt || {
79
+ number:
80
+ (Number(record.options?.resumeCount) || 0) +
81
+ (Number(record.options?.recoveryAttempts) || 0) +
82
+ 1,
83
+ startedAt: record.options?.resumedAt || record.startTime,
84
+ logOffset: null,
85
+ }),
86
+ command: record.command,
87
+ sessionName: record.options?.sessionName || null,
88
+ containerId: record.options?.containerId || null,
89
+ containerError: record.options?.containerError || null,
90
+ status: record.status,
91
+ exitCode: record.exitCode,
92
+ endTime: record.endTime,
93
+ };
94
+ for (const field of TERMINAL_FIELDS) {
95
+ if (record[field] !== undefined) {
96
+ snapshot[field] = record[field];
97
+ }
98
+ record[field] = undefined;
99
+ }
100
+ record.attemptHistory = [...(record.attemptHistory || []), snapshot];
101
+ delete record.options.containerError;
102
+ }
103
+
104
+ function appendLifecycle(record, event, details = {}) {
105
+ if (!record.logPath || !record.attempt) {
106
+ return;
107
+ }
108
+ appendLogFile(
109
+ record.logPath,
110
+ `\n[Start Command Lifecycle] ${JSON.stringify({
111
+ event,
112
+ at: new Date().toISOString(),
113
+ uuid: record.uuid,
114
+ attemptNumber: record.attempt.number,
115
+ ...record.attempt,
116
+ ...details,
117
+ })}\n`
118
+ );
119
+ }
120
+
121
+ function patchAttempt(store, record, fields) {
122
+ if (typeof store.patchAttempt === 'function') {
123
+ return store.patchAttempt(record.uuid, record.attempt.number, fields);
124
+ }
125
+ // Compatibility for consumers injecting a minimal in-memory store.
126
+ const current = store.get(record.uuid) || record;
127
+ if (current.attempt?.number !== record.attempt.number) {
128
+ return null;
129
+ }
130
+ current.attempt = { ...current.attempt, ...fields };
131
+ store.save(current);
132
+ return current;
133
+ }
134
+
135
+ function readAttemptLogTail(record, bytes) {
136
+ if (
137
+ !record.logPath ||
138
+ (record.attempt && record.attempt.logOffset === null)
139
+ ) {
140
+ return null;
141
+ }
142
+ return readLogTail(record.logPath, bytes, record.attempt?.logOffset || 0);
143
+ }
144
+
145
+ module.exports = {
146
+ activityPath,
147
+ appendLifecycle,
148
+ archiveAttempt,
149
+ createAttempt,
150
+ patchAttempt,
151
+ readAttemptActivity,
152
+ readAttemptLogTail,
153
+ };
@@ -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`);
@@ -318,15 +367,42 @@ function recoverKilledExecution(params = {}) {
318
367
  }
319
368
 
320
369
  const since = now().toISOString();
321
- const started = runner(getDockerCommand(), ['start', containerName]);
322
- if (!started.success) {
323
- return giveUp(`docker start failed: ${failureDetail(started)}`);
370
+ const {
371
+ appendLifecycle,
372
+ archiveAttempt,
373
+ createAttempt,
374
+ patchAttempt,
375
+ } = require('./execution-attempt');
376
+ const nextAttempt = record.attempt
377
+ ? createAttempt(record, {
378
+ mode: 'automatic-recovery',
379
+ sessionName: containerName,
380
+ })
381
+ : null;
382
+ if (nextAttempt) {
383
+ nextAttempt.startedAt = since;
384
+ appendLifecycle(
385
+ { uuid: record.uuid, logPath: record.logPath, attempt: nextAttempt },
386
+ 'resume-started'
387
+ );
324
388
  }
325
-
326
389
  const described = describeExitCode(params.exitCode);
327
390
  const delayed = delayRange ? `, delayMs=${delayMs}` : '';
328
391
  // The killed run's own counters: the resumed run starts a fresh cgroup.
329
392
  const memory = parseCgroupMemorySample(params.cgroupMemory);
393
+ if (nextAttempt) {
394
+ Object.assign(record, {
395
+ status: 'executed',
396
+ exitCode: described.code,
397
+ endTime: params.finishedAt || null,
398
+ containerStartedAt: params.startedAt || undefined,
399
+ oomKilled: params.oomKilled === true || params.oomKilled === 'true',
400
+ cgroupMemory: memory || undefined,
401
+ });
402
+ archiveAttempt(record);
403
+
404
+ record.attempt = nextAttempt;
405
+ }
330
406
  const counted = memory
331
407
  ? ['oomEvents', 'oomKills']
332
408
  .filter((field) => memory[field] !== null)
@@ -335,6 +411,18 @@ function recoverKilledExecution(params = {}) {
335
411
  : '';
336
412
  record.options = {
337
413
  ...opts,
414
+ ...(recoveryLimits
415
+ ? {
416
+ resolvedLimits: {
417
+ ...opts.resolvedLimits,
418
+ ...recoveryLimits.resolvedLimits,
419
+ },
420
+ resourceLimitSpecs: {
421
+ ...opts.resourceLimitSpecs,
422
+ memory: opts.onKillResumeMemory,
423
+ },
424
+ }
425
+ : {}),
338
426
  recoveryAttempts: attempt,
339
427
  recoveryHistory: [
340
428
  ...(Array.isArray(opts.recoveryHistory) ? opts.recoveryHistory : []),
@@ -344,28 +432,116 @@ function recoverKilledExecution(params = {}) {
344
432
  ...(delayRange ? { lastRecoveryDelayMs: delayMs } : {}),
345
433
  ...(resourceLimits && resourceLimits.length > 0 ? { resourceLimits } : {}),
346
434
  };
435
+ delete record.options.exitEvidence;
347
436
  record.status = 'executing';
348
437
  record.exitCode = null;
349
438
  record.endTime = null;
350
439
  record.exitReason = undefined;
351
440
  record.oomKilled = undefined;
352
441
  record.cgroupMemory = undefined;
442
+ record.memoryExhausted = undefined;
443
+ record.memoryExhaustedReason = undefined;
444
+ record.endTimeSource = undefined;
445
+ record.observedAt = undefined;
446
+ record.staleDetectedAt = undefined;
447
+ record.containerStartedAt = undefined;
448
+ const previousData = globalThis.structuredClone(store.get(executionId));
449
+ const previous = record.constructor.fromObject
450
+ ? record.constructor.fromObject(previousData)
451
+ : previousData;
452
+ try {
453
+ reserveLaunch(store, record, previous);
454
+ } catch (error) {
455
+ return giveUp(`launch reservation failed: ${error.message}`);
456
+ }
457
+ if (recoveryLimits || opts.cpuPenaltyConfig) {
458
+ const updated = runner(getDockerCommand(), [
459
+ 'update',
460
+ ...resourceLimits.filter((s) =>
461
+ /^--(?:memory|memory-swap|cpus|cpu-quota|cpu-period)=/.test(s)
462
+ ),
463
+ containerName,
464
+ ]);
465
+ if (!updated.success) {
466
+ rollbackLaunch(store, previous);
467
+ return giveUp(`docker update failed: ${failureDetail(updated)}`);
468
+ }
469
+ }
470
+ const started = runner(getDockerCommand(), ['start', containerName]);
471
+ if (!started.success) {
472
+ if (nextAttempt) {
473
+ appendLifecycle(
474
+ { uuid: record.uuid, logPath: record.logPath, attempt: nextAttempt },
475
+ 'launch-failed',
476
+ { error: failureDetail(started) }
477
+ );
478
+ }
479
+ rollbackLaunch(store, previous);
480
+ return giveUp(`docker start failed: ${failureDetail(started)}`);
481
+ }
482
+
483
+ if (opts.cpuPenaltyConfig) {
484
+ record.options.resolvedLimits = {
485
+ ...record.options.resolvedLimits,
486
+ cpus: require('./docker-resource-limits').cpuCount(resourceLimits),
487
+ };
488
+ }
489
+ record.options.launchPending = false;
490
+ if (nextAttempt) {
491
+ nextAttempt.launchAcceptedAt = new Date().toISOString();
492
+ }
493
+ let persistenceError = null;
353
494
  try {
354
495
  store.save(record);
355
- } catch {
356
- // The container is already running again; the new watcher still follows
357
- // it and finalizes the record when it ends.
496
+ } catch (error) {
497
+ persistenceError = error;
358
498
  }
359
499
 
360
500
  const startWatcher =
361
501
  params.startWatcher || startDetachedDockerCompletionWatcher;
362
- startWatcher(
363
- containerName,
364
- getDockerContainerCleanupPolicy(opts),
365
- record.logPath || null,
366
- record.uuid,
367
- { since, recoverOnKill: true }
368
- );
502
+ if (nextAttempt) {
503
+ appendLifecycle(record, 'launch-accepted');
504
+ }
505
+ try {
506
+ startWatcher(
507
+ containerName,
508
+ getDockerContainerCleanupPolicy(opts),
509
+ record.logPath || null,
510
+ record.uuid,
511
+ {
512
+ since,
513
+ recoverOnKill: true,
514
+ ...(nextAttempt ? { attemptNumber: nextAttempt.number } : {}),
515
+ }
516
+ );
517
+ } catch (error) {
518
+ const warning = failLaunchedPersistence(
519
+ store,
520
+ record,
521
+ runner,
522
+ persistenceError || error
523
+ );
524
+ log(`[Recovery] ${warning}\n`);
525
+ return { recovered: false, reason: warning, attempt };
526
+ }
527
+ if (nextAttempt && !persistenceError) {
528
+ const current = patchAttempt(store, record, {
529
+ watcherAttachedAt: new Date().toISOString(),
530
+ });
531
+ if (current) {
532
+ appendLifecycle(current, 'watcher-attached');
533
+ }
534
+ }
535
+ if (persistenceError) {
536
+ const warning = failLaunchedPersistence(
537
+ store,
538
+ record,
539
+ runner,
540
+ persistenceError
541
+ );
542
+ log(`[Recovery] ${warning}\n`);
543
+ return { recovered: false, reason: warning, attempt };
544
+ }
369
545
  return {
370
546
  recovered: true,
371
547
  reason: 'resumed',
@@ -125,13 +125,35 @@ 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(
131
142
  sessionName,
132
143
  getDockerContainerCleanupPolicy(record.options || {}),
133
144
  record.logPath || null,
134
- record.uuid || null
145
+ record.uuid || null,
146
+ record.attempt
147
+ ? {
148
+ since: record.attempt.startedAt,
149
+ attemptNumber: record.attempt.number,
150
+ recoverOnKill:
151
+ Boolean(record.options?.onKillResume) &&
152
+ ['docker-start', 'automatic-recovery', 'relaunch'].includes(
153
+ record.attempt.mode
154
+ ),
155
+ }
156
+ : {}
135
157
  );
136
158
  executions.push(
137
159
  describeRecord(