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
@@ -7,13 +7,13 @@
7
7
  * addressing one logical session across restarts.
8
8
  *
9
9
  * Three strategies, chosen from the probed session state:
10
- * - DOCKER_START: the container still exists and the stored command is
11
- * re-run by `docker start` (its original entrypoint).
12
- * - DOCKER_SNAPSHOT: the container still exists but a new command was given,
13
- * so its filesystem is committed to an image and a derived
14
- * container runs the new command. This avoids
15
- * `docker start -ai`, which would re-run the original
16
- * entrypoint from scratch.
10
+ * - DOCKER_START: the container still exists. Newly managed containers
11
+ * accept replacement commands through a small handoff
12
+ * script, then restart in place without a snapshot.
13
+ * - DOCKER_SNAPSHOT: a legacy container needs a replacement command, or
14
+ * immutable labels need changing. After disk preflight,
15
+ * its filesystem is committed under a host-wide lock and
16
+ * a derived container runs the command.
17
17
  * - RELAUNCH: nothing is left of the session, so the command is
18
18
  * launched again through the stored isolation options.
19
19
  */
@@ -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,8 +37,23 @@ 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');
47
+ const {
48
+ writeCommandHandoff,
49
+ buildCommandHandoffArgs,
50
+ commandHandoffPath,
51
+ } = require('./docker-command-handoff');
52
+ const {
53
+ acquireSnapshotLock,
54
+ preflightSnapshot,
55
+ SNAPSHOT_IMAGE_LABEL,
56
+ } = require('./docker-snapshot-safety');
43
57
  const {
44
58
  ResumeAllAction,
45
59
  resumeAllExecutions,
@@ -89,6 +103,12 @@ function buildResumedSessionName(sessionName, attempt) {
89
103
  function buildLaunchOptions(record) {
90
104
  const opts = record.options || {};
91
105
  return {
106
+ ...opts,
107
+ labels: opts.labels || [],
108
+ sessionId: record.uuid,
109
+ rootSession:
110
+ opts.rootSession || opts.sessionNameHistory?.[0] || opts.sessionName,
111
+ resumeCount: (Number(opts.resumeCount) || 0) + 1,
92
112
  image: opts.image || null,
93
113
  session: opts.sessionName,
94
114
  detached: true,
@@ -209,12 +229,13 @@ function buildResumePlan(
209
229
  const attempt = (Number(opts.resumeCount) || 0) + 1;
210
230
 
211
231
  if (backend === 'docker' && probe.state === SessionState.STOPPED) {
212
- if (!newCommand) {
232
+ if (!opts.forceSnapshot && (!newCommand || opts.commandHandoff)) {
213
233
  return {
214
234
  mode: ResumeMode.DOCKER_START,
215
235
  backend,
216
236
  sessionName,
217
237
  command,
238
+ ...(newCommand ? { handoffCommand: command } : {}),
218
239
  attempt,
219
240
  steps: [
220
241
  {
@@ -223,7 +244,9 @@ function buildResumePlan(
223
244
  description: `Start stopped container ${sessionName}`,
224
245
  },
225
246
  ],
226
- message: `Resumed detached docker container: ${sessionName}`,
247
+ message: newCommand
248
+ ? `Resumed new command in the same detached docker container: ${sessionName} (no filesystem snapshot)`
249
+ : `Resumed detached docker container: ${sessionName}`,
227
250
  };
228
251
  }
229
252
 
@@ -256,12 +279,24 @@ function buildResumePlan(
256
279
  containerArgs: [
257
280
  '--name',
258
281
  newSessionName,
282
+ '--label',
283
+ `${SNAPSHOT_IMAGE_LABEL}=${snapshotImage}`,
259
284
  ...(opts.user ? ['--user', opts.user] : []),
260
- ...buildDockerRuntimeArgs({ ...opts, resourceLimits }),
285
+ ...buildDockerRuntimeArgs({
286
+ ...opts,
287
+ resourceLimits,
288
+ session: newSessionName,
289
+ containerName: newSessionName,
290
+ sessionId: record.uuid,
291
+ rootSession:
292
+ opts.rootSession || opts.sessionNameHistory?.[0] || sessionName,
293
+ resumeCount: attempt,
294
+ }),
261
295
  snapshotImage,
262
- 'sh',
263
- '-c',
264
- command,
296
+ ...buildCommandHandoffArgs(
297
+ ['sh', '-c', command],
298
+ commandHandoffPath(newSessionName)
299
+ ),
265
300
  ],
266
301
  extraNetworks: getDockerNetworks(opts).slice(1),
267
302
  newSessionName,
@@ -355,10 +390,15 @@ function applyResumeToRecord(
355
390
  archiveAttempt(record);
356
391
  record.attempt = attempt;
357
392
  const options = { ...(record.options || {}) };
393
+ options.rootSession =
394
+ options.rootSession ||
395
+ options.sessionNameHistory?.[0] ||
396
+ options.sessionName;
358
397
  options.resumeCount = plan.attempt;
359
398
  options.resumedAt = attempt.startedAt;
360
399
  // A resume is a new deliberate start: launch-time recovery applies again.
361
400
  delete options.stopRequestedAt;
401
+ delete options.exitEvidence;
362
402
  if (plan.resourceLimits && plan.resourceLimits.length > 0) {
363
403
  options.resourceLimits = plan.resourceLimits;
364
404
  }
@@ -372,6 +412,12 @@ function applyResumeToRecord(
372
412
  }
373
413
  if (plan.snapshotImage) {
374
414
  options.image = plan.snapshotImage;
415
+ options.snapshotImage = plan.snapshotImage;
416
+ // Every new detached container, including a snapshot successor, supports handoff.
417
+ options.commandHandoff = true;
418
+ }
419
+ if (plan.mode === ResumeMode.RELAUNCH && plan.backend === 'docker') {
420
+ options.commandHandoff = true;
375
421
  }
376
422
  if (containerId) {
377
423
  options.containerId = containerId;
@@ -411,27 +457,83 @@ async function resumeExecution(store, identifier, deps = {}) {
411
457
  };
412
458
  }
413
459
 
460
+ if (require('./launch-owner').hasActiveLaunch(record)) {
461
+ return {
462
+ success: false,
463
+ error: 'An execution launch is already reserved.',
464
+ };
465
+ }
466
+ const previousData = globalThis.structuredClone(
467
+ record.toObject ? record.toObject() : record
468
+ );
469
+ const previous = record.constructor.fromObject
470
+ ? record.constructor.fromObject(previousData)
471
+ : previousData;
414
472
  const runner = deps.runner || runCommand;
415
473
  const probeFn = deps.probe || ((r) => probeSession(r, runner));
416
474
  const probe = probeFn(record);
417
475
  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;
476
+ let resources;
477
+ try {
478
+ resources = require('./resume-resources').prepareResumeResources(
479
+ record,
480
+ probe,
481
+ deps.resourceOptions || {},
482
+ runner,
483
+ deps.random
484
+ );
485
+ } catch (error) {
486
+ return { success: false, error: error.message };
487
+ }
488
+ const requestedLabels = deps.resourceOptions?.labels || [];
489
+ const labels = [...(opts.labels || [])];
490
+ for (const label of requestedLabels) {
491
+ const key = label.split('=')[0];
492
+ const existing = labels.findIndex((value) => value.split('=')[0] === key);
493
+ if (existing >= 0) {
494
+ labels[existing] = label;
495
+ } else {
496
+ labels.push(label);
497
+ }
498
+ }
499
+ if (requestedLabels.length && opts.isolated !== 'docker') {
500
+ return {
501
+ success: false,
502
+ error: 'Docker labels require a Docker execution when resuming.',
503
+ };
504
+ }
505
+ const labelsChanged =
506
+ JSON.stringify(labels) !== JSON.stringify(opts.labels || []);
507
+ const planningRecord = {
508
+ ...record,
509
+ options: {
510
+ ...opts,
511
+ ...(resources ? { resourceLimits: resources.resourceLimits } : {}),
512
+ ...(requestedLabels.length ? { labels } : {}),
513
+ ...(labelsChanged ? { forceSnapshot: true } : {}),
514
+ },
515
+ };
429
516
  const plan = buildResumePlan(
430
- record,
517
+ planningRecord,
431
518
  deps.command || null,
432
519
  probe,
433
- liveResourceLimits
520
+ resources?.resourceLimits
434
521
  );
522
+ if (resources && !plan.error) {
523
+ plan.resourceLimits = resources.resourceLimits;
524
+ if (plan.mode === ResumeMode.DOCKER_START && resources.update) {
525
+ plan.steps.unshift({
526
+ command: getDockerCommand(),
527
+ args: [
528
+ 'update',
529
+ ...resources.resourceLimits.filter((s) =>
530
+ /^--(?:memory|memory-swap|cpus|cpu-quota|cpu-period)=/.test(s)
531
+ ),
532
+ plan.sessionName,
533
+ ],
534
+ });
535
+ }
536
+ }
435
537
  if (plan.error) {
436
538
  return { success: false, error: plan.error };
437
539
  }
@@ -444,15 +546,53 @@ async function resumeExecution(store, identifier, deps = {}) {
444
546
  attempt,
445
547
  };
446
548
  appendLifecycle(lifecycleRecord, 'resume-started');
549
+ const updated = applyResumeToRecord(record, plan, null, attempt);
550
+ if (requestedLabels.length) {
551
+ updated.options.labels = labels;
552
+ }
553
+ if (resources) {
554
+ updated.options.resolvedLimits = resources.resolvedLimits;
555
+ updated.options.resourceLimitSpecs = resources.resourceLimitSpecs;
556
+ if (
557
+ opts.cpuPenaltyConfig &&
558
+ deps.resourceOptions?.cpus !== null &&
559
+ deps.resourceOptions?.cpus !== undefined
560
+ ) {
561
+ updated.options.baseResourceLimits = resources.resourceLimits;
562
+ }
563
+ }
564
+ try {
565
+ reserveLaunch(store, updated, previous);
566
+ } catch (error) {
567
+ appendLifecycle(lifecycleRecord, 'launch-failed', { error: error.message });
568
+ return {
569
+ success: false,
570
+ error: `Launch reservation failed: ${error.message}`,
571
+ };
572
+ }
573
+
574
+ if (resources && deps.resourceOptions?.memory && record.logPath) {
575
+ const oldMemory = resources.oldMemory;
576
+ appendLogFile(
577
+ record.logPath,
578
+ `Resume memory limit: ${oldMemory} -> ${resources.resolvedLimits.memory} (${deps.resourceOptions.memory})\n`
579
+ );
580
+ }
447
581
 
448
582
  if (plan.mode === ResumeMode.RELAUNCH) {
449
583
  const runIsolated = deps.runIsolated || require('./isolation').runIsolated;
450
- const launchResult = await runIsolated(
451
- plan.backend,
452
- plan.command,
453
- plan.launchOptions
454
- );
584
+ let launchResult;
585
+ try {
586
+ launchResult = await runIsolated(
587
+ plan.backend,
588
+ plan.command,
589
+ plan.launchOptions
590
+ );
591
+ } catch (error) {
592
+ launchResult = { success: false, message: error.message };
593
+ }
455
594
  if (!launchResult || !launchResult.success) {
595
+ rollbackLaunch(store, previous);
456
596
  appendLifecycle(lifecycleRecord, 'launch-failed', {
457
597
  error: launchResult?.message || 'unknown error',
458
598
  });
@@ -465,20 +605,108 @@ async function resumeExecution(store, identifier, deps = {}) {
465
605
  }
466
606
  containerId = launchResult.containerId || null;
467
607
  } else {
468
- for (const step of plan.steps) {
469
- const result = runner(step.command, step.args);
470
- if (!result.success) {
471
- const detail =
472
- (result.stderr || '').trim() ||
473
- result.error ||
474
- `exit code ${result.status}`;
475
- appendLifecycle(lifecycleRecord, 'launch-failed', { error: detail });
476
- return {
477
- success: false,
478
- error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${detail}`,
479
- };
608
+ let snapshotLock;
609
+ let snapshotCreated = false;
610
+ let derivedCreated = false;
611
+ try {
612
+ if (plan.mode === ResumeMode.DOCKER_SNAPSHOT) {
613
+ snapshotLock = acquireSnapshotLock(deps.snapshotOptions);
614
+ const preflight = preflightSnapshot(
615
+ plan.sessionName,
616
+ runner,
617
+ deps.snapshotOptions
618
+ );
619
+ plan.message += `\n${preflight.message}`;
620
+ if (record.logPath) {
621
+ appendLogFile(record.logPath, `${preflight.message}\n`);
622
+ }
623
+ }
624
+ if (plan.handoffCommand) {
625
+ const copied = writeCommandHandoff(
626
+ plan.sessionName,
627
+ plan.handoffCommand,
628
+ runner,
629
+ opts
630
+ );
631
+ if (!copied.success) {
632
+ throw new Error(
633
+ (copied.stderr || '').trim() ||
634
+ copied.error ||
635
+ 'Could not copy replacement command into the stopped container'
636
+ );
637
+ }
638
+ }
639
+ for (const step of plan.steps) {
640
+ let result;
641
+ try {
642
+ result = runner(step.command, step.args);
643
+ } catch (error) {
644
+ result = { success: false, error: error.message };
645
+ }
646
+ if (!result.success) {
647
+ const detail =
648
+ (result.stderr || '').trim() ||
649
+ result.error ||
650
+ `exit code ${result.status}`;
651
+ throw new Error(detail);
652
+ }
653
+ if (step.args[0] === 'commit') {
654
+ snapshotCreated = true;
655
+ }
656
+ if (step.args[0] === 'create') {
657
+ derivedCreated = true;
658
+ }
659
+ // commit returns an image ID; cp/update/network commands are not IDs.
660
+ if (['run', 'create'].includes(step.args[0])) {
661
+ containerId = (result.stdout || '').trim() || containerId;
662
+ }
663
+ }
664
+ if (plan.mode === ResumeMode.DOCKER_SNAPSHOT && deps.removeOriginal) {
665
+ const running = runner(getDockerCommand(), [
666
+ 'inspect',
667
+ '-f',
668
+ '{{.State.Running}}',
669
+ plan.newSessionName,
670
+ ]);
671
+ if (running.success && String(running.stdout).trim() === 'true') {
672
+ const removed = runner(getDockerCommand(), ['rm', plan.sessionName]);
673
+ const note = removed.success
674
+ ? `Stopped original container removed: ${plan.sessionName}`
675
+ : `Original container retained: ${(removed.stderr || removed.error || 'docker rm failed').trim()}`;
676
+ plan.message += `\n${note}`;
677
+ if (record.logPath) {
678
+ appendLogFile(record.logPath, `${note}\n`);
679
+ }
680
+ } else {
681
+ plan.message +=
682
+ '\nOriginal container retained: the snapshot container is not running.';
683
+ }
684
+ }
685
+ } catch (error) {
686
+ if (derivedCreated) {
687
+ runner(getDockerCommand(), ['rm', plan.newSessionName]);
688
+ }
689
+ if (snapshotCreated) {
690
+ runner(getDockerCommand(), ['rmi', plan.snapshotImage]);
480
691
  }
481
- containerId = (result.stdout || '').trim() || containerId;
692
+ if (plan.handoffCommand) {
693
+ // A failed start must not leave a replacement hidden behind the old record.
694
+ try {
695
+ writeCommandHandoff(plan.sessionName, previous.command, runner, opts);
696
+ } catch {
697
+ /* Next explicit command can still repair the handoff. */
698
+ }
699
+ }
700
+ rollbackLaunch(store, previous);
701
+ appendLifecycle(lifecycleRecord, 'launch-failed', {
702
+ error: error.message,
703
+ });
704
+ return {
705
+ success: false,
706
+ error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${error.message}`,
707
+ };
708
+ } finally {
709
+ snapshotLock?.release();
482
710
  }
483
711
 
484
712
  const limitsLine = buildResourceLimitsStatusLine(plan.resourceLimits);
@@ -489,8 +717,16 @@ async function resumeExecution(store, identifier, deps = {}) {
489
717
 
490
718
  const previousSessionName = plan.newSessionName ? plan.sessionName : null;
491
719
  attempt.launchAcceptedAt = new Date().toISOString();
492
- const updated = applyResumeToRecord(record, plan, containerId, attempt);
493
- store.save(updated);
720
+ updated.options.launchPending = false;
721
+ if (containerId) {
722
+ updated.options.containerId = containerId;
723
+ }
724
+ let persistenceError = null;
725
+ try {
726
+ store.save(updated);
727
+ } catch (error) {
728
+ persistenceError = error;
729
+ }
494
730
  appendLifecycle(updated, 'launch-accepted');
495
731
 
496
732
  if (plan.backend === 'docker') {
@@ -513,26 +749,50 @@ async function resumeExecution(store, identifier, deps = {}) {
513
749
  if (watcher === false) {
514
750
  throw new Error('Watcher process could not be started');
515
751
  }
516
- const current = patchAttempt(store, updated, {
517
- watcherAttachedAt: new Date().toISOString(),
518
- });
752
+ const current = persistenceError
753
+ ? null
754
+ : patchAttempt(store, updated, {
755
+ watcherAttachedAt: new Date().toISOString(),
756
+ });
519
757
  if (current) {
520
758
  appendLifecycle(current, 'watcher-attached');
521
759
  }
522
760
  } catch (error) {
523
- const current = patchAttempt(store, updated, {
524
- watcherError: error.message,
525
- });
761
+ if (persistenceError) {
762
+ return {
763
+ success: false,
764
+ error: failLaunchedPersistence(
765
+ store,
766
+ updated,
767
+ runner,
768
+ persistenceError
769
+ ),
770
+ };
771
+ }
772
+ const current = persistenceError
773
+ ? null
774
+ : patchAttempt(store, updated, {
775
+ watcherError: error.message,
776
+ });
526
777
  appendLifecycle(current || updated, 'watcher-attachment-failed', {
527
778
  error: error.message,
528
779
  });
529
780
  return {
530
781
  success: false,
531
- error: `Launch accepted, but completion watcher attachment failed: ${error.message}`,
782
+ error: `Launch accepted for container ${activeSessionName(plan)}, but completion watcher attachment failed: ${error.message}`,
532
783
  };
533
784
  }
534
785
  }
535
786
 
787
+ if (persistenceError) {
788
+ // The reservation remains durable and the watcher is attached. Stop the
789
+ // new container rather than allow an untracked command to keep running.
790
+ return {
791
+ success: false,
792
+ error: failLaunchedPersistence(store, updated, runner, persistenceError),
793
+ };
794
+ }
795
+
536
796
  return {
537
797
  success: true,
538
798
  output: formatResumeResult(