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
@@ -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`);
@@ -337,18 +386,6 @@ function recoverKilledExecution(params = {}) {
337
386
  'resume-started'
338
387
  );
339
388
  }
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
389
  const described = describeExitCode(params.exitCode);
353
390
  const delayed = delayRange ? `, delayMs=${delayMs}` : '';
354
391
  // The killed run's own counters: the resumed run starts a fresh cgroup.
@@ -363,7 +400,7 @@ function recoverKilledExecution(params = {}) {
363
400
  cgroupMemory: memory || undefined,
364
401
  });
365
402
  archiveAttempt(record);
366
- nextAttempt.launchAcceptedAt = new Date().toISOString();
403
+
367
404
  record.attempt = nextAttempt;
368
405
  }
369
406
  const counted = memory
@@ -374,6 +411,18 @@ function recoverKilledExecution(params = {}) {
374
411
  : '';
375
412
  record.options = {
376
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
+ : {}),
377
426
  recoveryAttempts: attempt,
378
427
  recoveryHistory: [
379
428
  ...(Array.isArray(opts.recoveryHistory) ? opts.recoveryHistory : []),
@@ -383,6 +432,7 @@ function recoverKilledExecution(params = {}) {
383
432
  ...(delayRange ? { lastRecoveryDelayMs: delayMs } : {}),
384
433
  ...(resourceLimits && resourceLimits.length > 0 ? { resourceLimits } : {}),
385
434
  };
435
+ delete record.options.exitEvidence;
386
436
  record.status = 'executing';
387
437
  record.exitCode = null;
388
438
  record.endTime = null;
@@ -395,11 +445,56 @@ function recoverKilledExecution(params = {}) {
395
445
  record.observedAt = undefined;
396
446
  record.staleDetectedAt = undefined;
397
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;
398
494
  try {
399
495
  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.
496
+ } catch (error) {
497
+ persistenceError = error;
403
498
  }
404
499
 
405
500
  const startWatcher =
@@ -407,18 +502,29 @@ function recoverKilledExecution(params = {}) {
407
502
  if (nextAttempt) {
408
503
  appendLifecycle(record, 'launch-accepted');
409
504
  }
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) {
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) {
422
528
  const current = patchAttempt(store, record, {
423
529
  watcherAttachedAt: new Date().toISOString(),
424
530
  });
@@ -426,6 +532,16 @@ function recoverKilledExecution(params = {}) {
426
532
  appendLifecycle(current, 'watcher-attached');
427
533
  }
428
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
+ }
429
545
  return {
430
546
  recovered: true,
431
547
  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(
@@ -28,7 +28,6 @@ const { escapeForLinksNotation } = require('./output-blocks');
28
28
  const {
29
29
  buildResourceLimitsStatusLine,
30
30
  normalizeResourceLimits,
31
- readDockerResourceLimits,
32
31
  } = require('./docker-resource-limits');
33
32
  const { appendLogFile } = require('./isolation-log-utils');
34
33
  const {
@@ -38,6 +37,11 @@ const {
38
37
  patchAttempt,
39
38
  } = require('./execution-attempt');
40
39
  const { runCommand } = require('./execution-control');
40
+ const {
41
+ reserveLaunch,
42
+ rollbackLaunch,
43
+ failLaunchedPersistence,
44
+ } = require('./launch-persistence');
41
45
  const { SessionState, probeSession } = require('./session-probe');
42
46
  const { getDockerNetworks } = require('./docker-network-lifecycle');
43
47
  const {
@@ -89,6 +93,7 @@ function buildResumedSessionName(sessionName, attempt) {
89
93
  function buildLaunchOptions(record) {
90
94
  const opts = record.options || {};
91
95
  return {
96
+ ...opts,
92
97
  image: opts.image || null,
93
98
  session: opts.sessionName,
94
99
  detached: true,
@@ -359,6 +364,7 @@ function applyResumeToRecord(
359
364
  options.resumedAt = attempt.startedAt;
360
365
  // A resume is a new deliberate start: launch-time recovery applies again.
361
366
  delete options.stopRequestedAt;
367
+ delete options.exitEvidence;
362
368
  if (plan.resourceLimits && plan.resourceLimits.length > 0) {
363
369
  options.resourceLimits = plan.resourceLimits;
364
370
  }
@@ -411,27 +417,61 @@ async function resumeExecution(store, identifier, deps = {}) {
411
417
  };
412
418
  }
413
419
 
420
+ if (require('./launch-owner').hasActiveLaunch(record)) {
421
+ return {
422
+ success: false,
423
+ error: 'An execution launch is already reserved.',
424
+ };
425
+ }
426
+ const previousData = globalThis.structuredClone(
427
+ record.toObject ? record.toObject() : record
428
+ );
429
+ const previous = record.constructor.fromObject
430
+ ? record.constructor.fromObject(previousData)
431
+ : previousData;
414
432
  const runner = deps.runner || runCommand;
415
433
  const probeFn = deps.probe || ((r) => probeSession(r, runner));
416
434
  const probe = probeFn(record);
417
435
  const opts = record.options || {};
418
- // Only a snapshot resume creates a new container, so only it needs the old
419
- // container's limits (issue #176).
420
- const liveResourceLimits =
421
- deps.command &&
422
- opts.isolated === 'docker' &&
423
- opts.sessionName &&
424
- probe &&
425
- !probe.alive &&
426
- probe.state === SessionState.STOPPED
427
- ? readDockerResourceLimits(opts.sessionName, runner)
428
- : null;
436
+ let resources;
437
+ try {
438
+ resources = require('./resume-resources').prepareResumeResources(
439
+ record,
440
+ probe,
441
+ deps.resourceOptions || {},
442
+ runner,
443
+ deps.random
444
+ );
445
+ } catch (error) {
446
+ return { success: false, error: error.message };
447
+ }
448
+ const planningRecord = resources
449
+ ? {
450
+ ...record,
451
+ options: { ...opts, resourceLimits: resources.resourceLimits },
452
+ }
453
+ : record;
429
454
  const plan = buildResumePlan(
430
- record,
455
+ planningRecord,
431
456
  deps.command || null,
432
457
  probe,
433
- liveResourceLimits
458
+ resources?.resourceLimits
434
459
  );
460
+ if (resources && !plan.error) {
461
+ plan.resourceLimits = resources.resourceLimits;
462
+ if (plan.mode === ResumeMode.DOCKER_START && resources.update) {
463
+ plan.steps.unshift({
464
+ command: getDockerCommand(),
465
+ args: [
466
+ 'update',
467
+ ...resources.resourceLimits.filter((s) =>
468
+ /^--(?:memory|memory-swap|cpus|cpu-quota|cpu-period)=/.test(s)
469
+ ),
470
+ plan.sessionName,
471
+ ],
472
+ });
473
+ }
474
+ }
435
475
  if (plan.error) {
436
476
  return { success: false, error: plan.error };
437
477
  }
@@ -444,15 +484,50 @@ async function resumeExecution(store, identifier, deps = {}) {
444
484
  attempt,
445
485
  };
446
486
  appendLifecycle(lifecycleRecord, 'resume-started');
487
+ const updated = applyResumeToRecord(record, plan, null, attempt);
488
+ if (resources) {
489
+ updated.options.resolvedLimits = resources.resolvedLimits;
490
+ updated.options.resourceLimitSpecs = resources.resourceLimitSpecs;
491
+ if (
492
+ opts.cpuPenaltyConfig &&
493
+ deps.resourceOptions?.cpus !== null &&
494
+ deps.resourceOptions?.cpus !== undefined
495
+ ) {
496
+ updated.options.baseResourceLimits = resources.resourceLimits;
497
+ }
498
+ }
499
+ try {
500
+ reserveLaunch(store, updated, previous);
501
+ } catch (error) {
502
+ appendLifecycle(lifecycleRecord, 'launch-failed', { error: error.message });
503
+ return {
504
+ success: false,
505
+ error: `Launch reservation failed: ${error.message}`,
506
+ };
507
+ }
508
+
509
+ if (resources && deps.resourceOptions?.memory && record.logPath) {
510
+ const oldMemory = resources.oldMemory;
511
+ appendLogFile(
512
+ record.logPath,
513
+ `Resume memory limit: ${oldMemory} -> ${resources.resolvedLimits.memory} (${deps.resourceOptions.memory})\n`
514
+ );
515
+ }
447
516
 
448
517
  if (plan.mode === ResumeMode.RELAUNCH) {
449
518
  const runIsolated = deps.runIsolated || require('./isolation').runIsolated;
450
- const launchResult = await runIsolated(
451
- plan.backend,
452
- plan.command,
453
- plan.launchOptions
454
- );
519
+ let launchResult;
520
+ try {
521
+ launchResult = await runIsolated(
522
+ plan.backend,
523
+ plan.command,
524
+ plan.launchOptions
525
+ );
526
+ } catch (error) {
527
+ launchResult = { success: false, message: error.message };
528
+ }
455
529
  if (!launchResult || !launchResult.success) {
530
+ rollbackLaunch(store, previous);
456
531
  appendLifecycle(lifecycleRecord, 'launch-failed', {
457
532
  error: launchResult?.message || 'unknown error',
458
533
  });
@@ -466,8 +541,14 @@ async function resumeExecution(store, identifier, deps = {}) {
466
541
  containerId = launchResult.containerId || null;
467
542
  } else {
468
543
  for (const step of plan.steps) {
469
- const result = runner(step.command, step.args);
544
+ let result;
545
+ try {
546
+ result = runner(step.command, step.args);
547
+ } catch (error) {
548
+ result = { success: false, error: error.message };
549
+ }
470
550
  if (!result.success) {
551
+ rollbackLaunch(store, previous);
471
552
  const detail =
472
553
  (result.stderr || '').trim() ||
473
554
  result.error ||
@@ -489,8 +570,16 @@ async function resumeExecution(store, identifier, deps = {}) {
489
570
 
490
571
  const previousSessionName = plan.newSessionName ? plan.sessionName : null;
491
572
  attempt.launchAcceptedAt = new Date().toISOString();
492
- const updated = applyResumeToRecord(record, plan, containerId, attempt);
493
- store.save(updated);
573
+ updated.options.launchPending = false;
574
+ if (containerId) {
575
+ updated.options.containerId = containerId;
576
+ }
577
+ let persistenceError = null;
578
+ try {
579
+ store.save(updated);
580
+ } catch (error) {
581
+ persistenceError = error;
582
+ }
494
583
  appendLifecycle(updated, 'launch-accepted');
495
584
 
496
585
  if (plan.backend === 'docker') {
@@ -513,26 +602,50 @@ async function resumeExecution(store, identifier, deps = {}) {
513
602
  if (watcher === false) {
514
603
  throw new Error('Watcher process could not be started');
515
604
  }
516
- const current = patchAttempt(store, updated, {
517
- watcherAttachedAt: new Date().toISOString(),
518
- });
605
+ const current = persistenceError
606
+ ? null
607
+ : patchAttempt(store, updated, {
608
+ watcherAttachedAt: new Date().toISOString(),
609
+ });
519
610
  if (current) {
520
611
  appendLifecycle(current, 'watcher-attached');
521
612
  }
522
613
  } catch (error) {
523
- const current = patchAttempt(store, updated, {
524
- watcherError: error.message,
525
- });
614
+ if (persistenceError) {
615
+ return {
616
+ success: false,
617
+ error: failLaunchedPersistence(
618
+ store,
619
+ updated,
620
+ runner,
621
+ persistenceError
622
+ ),
623
+ };
624
+ }
625
+ const current = persistenceError
626
+ ? null
627
+ : patchAttempt(store, updated, {
628
+ watcherError: error.message,
629
+ });
526
630
  appendLifecycle(current || updated, 'watcher-attachment-failed', {
527
631
  error: error.message,
528
632
  });
529
633
  return {
530
634
  success: false,
531
- error: `Launch accepted, but completion watcher attachment failed: ${error.message}`,
635
+ error: `Launch accepted for container ${activeSessionName(plan)}, but completion watcher attachment failed: ${error.message}`,
532
636
  };
533
637
  }
534
638
  }
535
639
 
640
+ if (persistenceError) {
641
+ // The reservation remains durable and the watcher is attached. Stop the
642
+ // new container rather than allow an untracked command to keep running.
643
+ return {
644
+ success: false,
645
+ error: failLaunchedPersistence(store, updated, runner, persistenceError),
646
+ };
647
+ }
648
+
536
649
  return {
537
650
  success: true,
538
651
  output: formatResumeResult(