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
@@ -181,8 +181,16 @@ function resolveMemoryExhaustion(input) {
181
181
  return null;
182
182
  }
183
183
 
184
+ const evidence =
185
+ input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
186
+ if (evidence.daemonRestart) {
187
+ return null;
188
+ }
184
189
  const marker = detectMemoryMarker(input.logTail);
185
- if (marker) {
190
+ if (
191
+ marker &&
192
+ !(marker.reason.includes('kernel-oom-killer') && !evidence.mainOom)
193
+ ) {
186
194
  return { memoryExhausted: true, memoryExhaustedReason: marker.line };
187
195
  }
188
196
  if (input.oomKilled === true && isOomKillOfCommand(input)) {
@@ -230,9 +238,9 @@ function isOomKillOfCommand(input) {
230
238
  if (!input) {
231
239
  return false;
232
240
  }
233
- const observed =
234
- input.oomKilled === true || cgroupOomKills(input.cgroupMemory) > 0;
235
- if (!observed) {
241
+ const evidence =
242
+ input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
243
+ if (!evidence.mainOom || evidence.daemonRestart) {
236
244
  return false;
237
245
  }
238
246
  const { exitCode } = input;
@@ -318,8 +326,16 @@ function resolveExitReason(input) {
318
326
  return null;
319
327
  }
320
328
 
329
+ const evidence =
330
+ input.exitEvidence || require('./exit-evidence').fromLog(input.logTail);
331
+ if (evidence.daemonRestart) {
332
+ return 'killed (docker daemon restart)';
333
+ }
321
334
  const fromLog = detectExitReason(input.logTail);
322
- if (fromLog) {
335
+ if (
336
+ fromLog &&
337
+ !(fromLog.includes('kernel-oom-killer') && !evidence.mainOom)
338
+ ) {
323
339
  return fromLog;
324
340
  }
325
341
 
@@ -328,7 +344,9 @@ function resolveExitReason(input) {
328
344
  }
329
345
 
330
346
  const signalName = signalNameForExitCode(input.exitCode);
331
- return signalName ? `signal (${signalName})` : null;
347
+ return signalName
348
+ ? `signal (${signalName}${signalName === 'SIGKILL' ? '; cause unknown' : ''})`
349
+ : null;
332
350
  }
333
351
 
334
352
  module.exports = {
@@ -240,16 +240,16 @@ const FATAL_MARKER_TAIL_BYTES = 64 * 1024;
240
240
  * @param {number} [bytes] - Maximum number of trailing bytes to read
241
241
  * @returns {string|null} Tail content, or null when the file cannot be read
242
242
  */
243
- function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
243
+ function readLogTail(logPath, bytes = LOG_TAIL_BYTES, offset = 0) {
244
244
  let fd;
245
245
  try {
246
246
  fd = fs.openSync(logPath, 'r');
247
247
  const size = fs.fstatSync(fd).size;
248
- const length = Math.min(size, bytes);
248
+ const length = Math.min(Math.max(0, size - offset), bytes);
249
249
  const buffer = Buffer.alloc(length);
250
250
  fs.readSync(fd, buffer, 0, length, size - length);
251
251
  const tail = buffer.toString('utf8');
252
- if (size <= bytes) {
252
+ if (size - length === offset || length === 0) {
253
253
  return tail;
254
254
  }
255
255
  const firstNewline = tail.indexOf('\n');
@@ -547,6 +547,23 @@ function runInDocker(command, options = {}) {
547
547
  });
548
548
  }
549
549
 
550
+ if (!options.resolvedLimits) {
551
+ try {
552
+ Object.assign(
553
+ options,
554
+ require('./docker-resource-options').resolveResourceOptions(
555
+ options,
556
+ require('./execution-control').runCommand
557
+ )
558
+ );
559
+ } catch (error) {
560
+ return Promise.resolve({
561
+ success: false,
562
+ containerName: null,
563
+ message: error.message,
564
+ });
565
+ }
566
+ }
550
567
  const containerName = options.session || generateSessionName('docker');
551
568
  const containerExistedBeforeLaunch =
552
569
  readDockerContainerStatus(containerName) !== null;
@@ -659,13 +676,15 @@ function runInDocker(command, options = {}) {
659
676
  }
660
677
  }
661
678
 
662
- startDetachedDockerCompletionWatcher(
663
- containerName,
664
- cleanupPolicy,
665
- options.logPath,
666
- options.executionId || null,
667
- { recoverOnKill: Boolean(options.onKillResume) }
668
- );
679
+ if (!options.deferCompletionWatcher) {
680
+ startDetachedDockerCompletionWatcher(
681
+ containerName,
682
+ cleanupPolicy,
683
+ options.logPath,
684
+ options.executionId || null,
685
+ { recoverOnKill: Boolean(options.onKillResume), cpuOptions: options }
686
+ );
687
+ }
669
688
 
670
689
  let message = `Command started in detached docker container: ${containerName}`;
671
690
  message += `\nContainer ID: ${containerId.substring(0, 12)}`;
@@ -743,9 +762,14 @@ function runInDocker(command, options = {}) {
743
762
  const launchArgs = needsNetworkSetup
744
763
  ? ['start', '-a', '-i', containerName]
745
764
  : dockerArgs;
765
+ const diagnostics =
766
+ require('./attached-diagnostics').startAttachedDiagnostics(
767
+ containerName,
768
+ options
769
+ );
746
770
  const child = spawnAttachedDocker(launchArgs, options.logPath);
747
771
 
748
- child.on('exit', (code) => {
772
+ child.on('exit', async (code) => {
749
773
  const durationMs = Date.now() - startTime;
750
774
  const exitCode = code ?? 1;
751
775
  let message = `Docker container "${containerName}" exited with code ${exitCode}`;
@@ -760,6 +784,7 @@ function runInDocker(command, options = {}) {
760
784
  }
761
785
 
762
786
  const dockerState = readDockerContainerState(containerName);
787
+ const diagnosticFacts = await diagnostics.finish(dockerState);
763
788
  const oomKilled = dockerState ? dockerState.oomKilled : null;
764
789
  const postMortem = (removed) =>
765
790
  recordAttachedDockerPostMortem({
@@ -804,12 +829,14 @@ function runInDocker(command, options = {}) {
804
829
  resolve({
805
830
  success: exitCode === 0,
806
831
  containerName,
832
+ diagnostics: diagnosticFacts,
807
833
  message,
808
834
  exitCode,
809
835
  });
810
836
  });
811
837
 
812
- child.on('error', (err) => {
838
+ child.on('error', async (err) => {
839
+ await diagnostics.finish(null);
813
840
  resolve({
814
841
  success: false,
815
842
  containerName,
@@ -0,0 +1,25 @@
1
+ /** A dead launcher leaves a usable reservation, rather than a permanent gate. */
2
+ const os = require('os');
3
+
4
+ function markLaunch(record) {
5
+ record.options.launchPending = true;
6
+ record.options.launchOwner = { pid: process.pid, hostname: os.hostname() };
7
+ }
8
+
9
+ function hasActiveLaunch(record) {
10
+ if (!record?.options?.launchPending) {
11
+ return false;
12
+ }
13
+ const owner = record.options.launchOwner;
14
+ if (!owner || owner.hostname !== os.hostname()) {
15
+ return true;
16
+ }
17
+ try {
18
+ process.kill(owner.pid, 0);
19
+ return true;
20
+ } catch (error) {
21
+ return error.code !== 'ESRCH';
22
+ }
23
+ }
24
+
25
+ module.exports = { markLaunch, hasActiveLaunch };
@@ -0,0 +1,54 @@
1
+ /** Reserve before launch, and identify/stop a launch whose final save failed. */
2
+ const { getDockerCommand } = require('./docker-cleanup');
3
+
4
+ function reserveLaunch(store, record, previous) {
5
+ require('./launch-owner').markLaunch(record);
6
+ if (typeof store.reserveLaunch === 'function') {
7
+ store.reserveLaunch(record, previous);
8
+ } else {
9
+ store.save(record);
10
+ }
11
+ }
12
+
13
+ function rollbackLaunch(store, previous) {
14
+ try {
15
+ store.save(previous);
16
+ } catch {
17
+ /* The durable reservation still names the session. */
18
+ }
19
+ }
20
+
21
+ function failLaunchedPersistence(store, record, runner, error) {
22
+ const name = record.options.sessionName;
23
+ let stopped;
24
+ try {
25
+ stopped =
26
+ record.options.isolated === 'docker'
27
+ ? runner(getDockerCommand(), ['stop', name])
28
+ : null;
29
+ } catch {
30
+ stopped = null;
31
+ }
32
+ const warning = {
33
+ code: 'LAUNCH_PERSISTENCE_FAILED',
34
+ uuid: record.uuid,
35
+ containerName: name,
36
+ running: !stopped?.success,
37
+ error: error.message,
38
+ };
39
+ if (stopped?.success) {
40
+ record.status = 'executed';
41
+ record.exitCode = -1;
42
+ record.endTime = new Date().toISOString();
43
+ record.endTimeSource = 'observed-at';
44
+ record.options.launchPending = false;
45
+ try {
46
+ store.save(record);
47
+ } catch {
48
+ /* Completion watcher remains attached. */
49
+ }
50
+ }
51
+ return JSON.stringify(warning);
52
+ }
53
+
54
+ module.exports = { reserveLaunch, rollbackLaunch, failLaunchedPersistence };
@@ -55,9 +55,19 @@ function handleAttachQuery(store, identifier, readOnly) {
55
55
  return reportResult(attachExecution(store, identifier, { readOnly }));
56
56
  }
57
57
 
58
- async function handleResumeQuery(store, identifier, command, outputFormat) {
58
+ async function handleResumeQuery(
59
+ store,
60
+ identifier,
61
+ command,
62
+ outputFormat,
63
+ resourceOptions = {}
64
+ ) {
59
65
  return reportResult(
60
- await resumeExecution(store, identifier, { command, outputFormat })
66
+ await resumeExecution(store, identifier, {
67
+ command,
68
+ outputFormat,
69
+ resourceOptions,
70
+ })
61
71
  );
62
72
  }
63
73
 
@@ -174,7 +184,8 @@ async function dispatchQueryCommand(options, context = {}) {
174
184
  store,
175
185
  options.resume,
176
186
  context.command || null,
177
- outputFormat
187
+ outputFormat,
188
+ options
178
189
  );
179
190
  }
180
191
  if (options.resumeAll) {
@@ -0,0 +1,88 @@
1
+ const {
2
+ normalizeResourceLimits,
3
+ cpuCount,
4
+ withCpuLimit,
5
+ readDockerResourceLimits,
6
+ } = require('./docker-resource-limits');
7
+ const { resolveResourceOptions } = require('./docker-resource-options');
8
+
9
+ // Temporary penalty quotas must never become a resumed execution's base quota.
10
+ function restoreBaseCpu(limits, options) {
11
+ if (!options.cpuPenaltyConfig) {
12
+ return limits;
13
+ }
14
+ const base = normalizeResourceLimits(
15
+ options.baseResourceLimits || options.resourceLimits
16
+ );
17
+ return [
18
+ ...limits.filter((s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
19
+ ...base.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
20
+ ];
21
+ }
22
+
23
+ function prepareResumeResources(record, probe, overrides, runner, random) {
24
+ const options = record.options || {};
25
+ if (options.isolated !== 'docker') {
26
+ if (
27
+ ['memory', 'memorySwap', 'cpus'].some(
28
+ (k) => overrides[k] !== null && overrides[k] !== undefined
29
+ )
30
+ ) {
31
+ throw new Error('Resource overrides require a Docker execution');
32
+ }
33
+ return null;
34
+ }
35
+ const live =
36
+ probe.state === 'stopped'
37
+ ? readDockerResourceLimits(options.sessionName, runner)
38
+ : null;
39
+ const base = restoreBaseCpu(
40
+ normalizeResourceLimits(live || options.resourceLimits),
41
+ options
42
+ );
43
+ const oldMemory = base.find((s) => s.startsWith('--memory='))?.slice(9);
44
+ const result = resolveResourceOptions(
45
+ { ...overrides, resourceLimits: base },
46
+ runner,
47
+ random
48
+ );
49
+ if (overrides.cpus && probe.state === 'stopped') {
50
+ // Docker cannot switch an existing legacy quota to NanoCPUs in place.
51
+ const cpu = withCpuLimit(base, result.resolvedLimits.cpus);
52
+ result.resourceLimits = [
53
+ ...result.resourceLimits.filter(
54
+ (s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)
55
+ ),
56
+ ...cpu.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
57
+ ];
58
+ }
59
+ result.oldMemory = oldMemory
60
+ ? require('./docker-resource-options').parseResourceSpec(oldMemory).amount
61
+ : 'unlimited';
62
+ result.resolvedLimits = {
63
+ ...options.resolvedLimits,
64
+ ...result.resolvedLimits,
65
+ };
66
+ result.resourceLimitSpecs = {
67
+ ...options.resourceLimitSpecs,
68
+ ...result.resourceLimitSpecs,
69
+ };
70
+ result.update =
71
+ probe.state === 'stopped' &&
72
+ (['memory', 'memorySwap', 'cpus'].some(
73
+ (k) => overrides[k] !== null && overrides[k] !== undefined
74
+ ) ||
75
+ Boolean(options.cpuPenaltyConfig));
76
+ if (options.cpuPenaltyConfig && !overrides.cpus) {
77
+ result.resourceLimits = clampCpuToDaemon(result.resourceLimits, runner);
78
+ result.resolvedLimits.cpus = cpuCount(result.resourceLimits);
79
+ }
80
+ return result;
81
+ }
82
+
83
+ function clampCpuToDaemon(limits, runner) {
84
+ const { readDaemonCapacity } = require('./docker-resource-options');
85
+ const capacity = readDaemonCapacity(runner).cpus;
86
+ return withCpuLimit(limits, Math.min(cpuCount(limits) || capacity, capacity));
87
+ }
88
+ module.exports = { prepareResumeResources, restoreBaseCpu, clampCpuToDaemon };
@@ -94,7 +94,15 @@ function readDockerState(record) {
94
94
  if (opts.isolated !== 'docker' || !opts.sessionName) {
95
95
  return null;
96
96
  }
97
- return inspectDockerState(opts.sessionName);
97
+ const state = inspectDockerState(opts.sessionName);
98
+ if (
99
+ record.attempt &&
100
+ state?.startedAt &&
101
+ Date.parse(state.startedAt) < Date.parse(record.attempt.startedAt)
102
+ ) {
103
+ return null;
104
+ }
105
+ return state;
98
106
  }
99
107
 
100
108
  /**
@@ -318,6 +326,9 @@ function applyEndTime(enriched, dockerState, footerFinishedAt) {
318
326
  }
319
327
 
320
328
  function resolveDetachedStatus(record, logTail) {
329
+ if (require('./launch-owner').hasActiveLaunch(record)) {
330
+ return record;
331
+ }
321
332
  const footer = parseFooterFromTail(logTail);
322
333
  const footerExit = footer.exitCode;
323
334
  const dockerState = isDetachedDockerRecord(record)
@@ -436,6 +447,7 @@ function attachExitReason(record, logTail) {
436
447
  logTail,
437
448
  oomKilled: record.oomKilled,
438
449
  cgroupMemory: record.cgroupMemory,
450
+ exitEvidence: record.options?.exitEvidence,
439
451
  });
440
452
 
441
453
  if (!exitReason) {
@@ -482,9 +494,22 @@ function attachMemoryExhaustion(record, logTail) {
482
494
  logTail,
483
495
  oomKilled: record.oomKilled,
484
496
  cgroupMemory: record.cgroupMemory,
497
+ exitEvidence: record.options?.exitEvidence,
485
498
  });
486
499
 
487
500
  if (!memory) {
501
+ if (
502
+ record.memoryExhaustedReason?.startsWith('Docker reported') ||
503
+ record.memoryExhaustedReason?.startsWith('cgroup memory.events')
504
+ ) {
505
+ const cleared = Object.assign(
506
+ Object.create(Object.getPrototypeOf(record)),
507
+ record
508
+ );
509
+ delete cleared.memoryExhausted;
510
+ delete cleared.memoryExhaustedReason;
511
+ return cleared;
512
+ }
488
513
  return record;
489
514
  }
490
515
 
@@ -506,12 +531,34 @@ function attachMemoryExhaustion(record, logTail) {
506
531
  * @returns {Object} Possibly updated execution record
507
532
  */
508
533
  function enrichDetachedStatus(record) {
509
- const logTail =
510
- record && record.logPath
511
- ? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
512
- : null;
534
+ if (!record) {
535
+ return record;
536
+ }
537
+ const cpuPenalty = require('./cpu-penalty-monitor').readState(record);
538
+ if (cpuPenalty) {
539
+ record = Object.assign(
540
+ Object.create(Object.getPrototypeOf(record)),
541
+ record
542
+ );
543
+ record.cpuPenalty = cpuPenalty;
544
+ }
545
+ const {
546
+ readAttemptActivity,
547
+ readAttemptLogTail,
548
+ } = require('./execution-attempt');
549
+ const logTail = record
550
+ ? readAttemptLogTail(record, FATAL_MARKER_TAIL_BYTES)
551
+ : null;
513
552
  const resolved = resolveDetachedStatus(record, logTail);
514
- return attachMemoryExhaustion(attachExitReason(resolved, logTail), logTail);
553
+ const enriched = attachMemoryExhaustion(
554
+ attachExitReason(resolved, logTail),
555
+ logTail
556
+ );
557
+ const copy = Object.assign(
558
+ Object.create(Object.getPrototypeOf(enriched)),
559
+ enriched
560
+ );
561
+ return readAttemptActivity(copy);
515
562
  }
516
563
 
517
564
  /**
@@ -671,6 +718,9 @@ function formatRecordAsText(record) {
671
718
  ...(formatCgroupMemory(obj.cgroupMemory)
672
719
  ? [`Cgroup Memory: ${formatCgroupMemory(obj.cgroupMemory)}`]
673
720
  : []),
721
+ ...(obj.cpuPenalty
722
+ ? [`CPU Penalty: ${JSON.stringify(obj.cpuPenalty)}`]
723
+ : []),
674
724
  `PID: ${obj.pid !== null ? obj.pid : 'N/A'}`,
675
725
  `Working Directory: ${obj.workingDirectory}`,
676
726
  `Shell: ${obj.shell}`,
@@ -693,6 +743,16 @@ function formatRecordAsText(record) {
693
743
  lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
694
744
  }
695
745
  lines.push(`Log Path: ${obj.logPath}`);
746
+ if (obj.attempt) {
747
+ lines.push(
748
+ `Attempt: ${obj.attempt.number}`,
749
+ `Attempt Started: ${obj.attempt.startedAt}`,
750
+ `Attempt Log Offset: ${obj.attempt.logOffset ?? 'N/A'}`,
751
+ `Launch Accepted: ${obj.attempt.launchAcceptedAt || 'N/A'}`,
752
+ `Watcher Attached: ${obj.attempt.watcherAttachedAt || 'N/A'}`,
753
+ `Last Output: ${obj.attempt.lastOutputAt || 'N/A'}`
754
+ );
755
+ }
696
756
 
697
757
  // Format options as nested list instead of JSON
698
758
  const optionEntries = Object.entries(obj.options || {}).filter(
@@ -0,0 +1,133 @@
1
+ /** Populate before publishing: a failed write must never poison the shared lock. */
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+ const LOCK_TIMEOUT_MS = 30000;
6
+ const LOCK_STALE_MS = 60000;
7
+ const MALFORMED_LOCK_GRACE_MS = 3000;
8
+
9
+ class LockManager {
10
+ constructor(lockFilePath, filesystem = fs) {
11
+ this.lockFilePath = lockFilePath;
12
+ this.fs = filesystem;
13
+ this.lockAcquired = false;
14
+ this.identity = null;
15
+ }
16
+
17
+ acquire(timeout = LOCK_TIMEOUT_MS) {
18
+ const started = Date.now();
19
+ const debug = (message) => {
20
+ if (process.env.START_DEBUG === '1') {
21
+ console.error(`[DEBUG] Store lock ${this.lockFilePath}: ${message}`);
22
+ }
23
+ };
24
+ while (Date.now() - started < timeout) {
25
+ const temp = `${this.lockFilePath}.${process.pid}.${crypto.randomUUID()}`;
26
+ try {
27
+ try {
28
+ const stat = this.fs.statSync(this.lockFilePath);
29
+ if (this.isLockStale(this.readLockFile(), stat)) {
30
+ const current = this.fs.statSync(this.lockFilePath);
31
+ if (current.ino === stat.ino && current.mtimeMs === stat.mtimeMs) {
32
+ debug('reclaiming stale or malformed lock');
33
+ this.fs.unlinkSync(this.lockFilePath);
34
+ }
35
+ }
36
+ } catch (error) {
37
+ if (error.code !== 'ENOENT') {
38
+ throw error;
39
+ }
40
+ }
41
+ this.fs.writeFileSync(
42
+ temp,
43
+ JSON.stringify({
44
+ pid: process.pid,
45
+ timestamp: Date.now(),
46
+ hostname: os.hostname(),
47
+ }),
48
+ { flag: 'wx' }
49
+ );
50
+ const handle = this.fs.openSync(temp, 'r+');
51
+ try {
52
+ this.fs.fsyncSync(handle);
53
+ } finally {
54
+ this.fs.closeSync(handle);
55
+ }
56
+ const identity = this.fs.statSync(temp);
57
+ this.fs.linkSync(temp, this.lockFilePath);
58
+ this.identity = identity;
59
+ this.lockAcquired = true;
60
+ debug(`acquired after ${Date.now() - started}ms`);
61
+ return true;
62
+ } catch (error) {
63
+ if (error.code !== 'EEXIST') {
64
+ throw error;
65
+ }
66
+ this.sleep(100);
67
+ } finally {
68
+ try {
69
+ this.fs.unlinkSync(temp);
70
+ } catch {
71
+ /* May not have been created. */
72
+ }
73
+ }
74
+ }
75
+ debug('acquisition timed out');
76
+ return false;
77
+ }
78
+
79
+ release() {
80
+ if (!this.lockAcquired) {
81
+ return;
82
+ }
83
+ try {
84
+ const current = this.fs.statSync(this.lockFilePath);
85
+ if (
86
+ current.dev === this.identity.dev &&
87
+ current.ino === this.identity.ino
88
+ ) {
89
+ this.fs.unlinkSync(this.lockFilePath);
90
+ }
91
+ } catch {
92
+ /* The lock may already be gone. */
93
+ }
94
+ this.lockAcquired = false;
95
+ }
96
+
97
+ readLockFile() {
98
+ try {
99
+ return JSON.parse(this.fs.readFileSync(this.lockFilePath, 'utf8'));
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ isLockStale(data, stat) {
106
+ if (
107
+ !data ||
108
+ !Number.isFinite(data.timestamp) ||
109
+ !Number.isInteger(data.pid) ||
110
+ data.pid <= 0 ||
111
+ data.pid > 2147483647
112
+ ) {
113
+ return Boolean(
114
+ stat && Date.now() - stat.mtimeMs >= MALFORMED_LOCK_GRACE_MS
115
+ );
116
+ }
117
+ if (data.hostname === os.hostname()) {
118
+ try {
119
+ process.kill(data.pid, 0);
120
+ return false;
121
+ } catch (error) {
122
+ return error.code === 'ESRCH';
123
+ }
124
+ }
125
+ return Date.now() - data.timestamp > LOCK_STALE_MS;
126
+ }
127
+
128
+ sleep(ms) {
129
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
130
+ }
131
+ }
132
+
133
+ module.exports = { LockManager, LOCK_TIMEOUT_MS, MALFORMED_LOCK_GRACE_MS };
package/src/lib/usage.js CHANGED
@@ -19,6 +19,17 @@ Options:
19
19
  --on-kill-resume <N> Resume a killed (exit 137 / OOM) detached docker session up to N times
20
20
  --recovery-command <cmd> Command to run in the same container on such a resume
21
21
  --on-kill-resume-delay <min[-max]> Wait a random number of seconds before each such resume (default 0)
22
+ --memory <size|N%|MIN%-MAX%> Docker RAM cap; percentages use daemon capacity
23
+ --memory-swap <spec> Combined memory+swap cap (defaults to memory, no extra swap)
24
+ --cpus <n|N%|MIN%-MAX%> Docker CPU cap; ranges draw once per creation
25
+ --on-kill-resume-memory <spec> New memory cap before a qualifying OOM recovery
26
+ --cpu-penalty Enable delayed Docker CPU penalty (default off)
27
+ --cpu-penalty-cpus <n> Penalty cap (default 2 CPUs)
28
+ --cpu-penalty-trigger <p%> Busy threshold (default 95% of usable CPUs)
29
+ --cpu-penalty-trigger-window <d> Fully covered busy window (default 15m)
30
+ --cpu-penalty-release <p%> Quiet threshold (default 65% of penalty cap)
31
+ --cpu-penalty-release-window <d> Minimum quiet/capped window (default 15m)
32
+ Durations accept ms, s, m, h; --status reports penalty state
22
33
  --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
23
34
  --isolated-user, -u [name] Create isolated user with same permissions
24
35
  --keep-user Keep isolated user after command completes
@@ -305,6 +305,9 @@ describe('buildDockerRuntimeMetadata', () => {
305
305
  networks: null,
306
306
  networkAliases: null,
307
307
  resourceLimits: null,
308
+ resolvedLimits: null,
309
+ resourceLimitSpecs: null,
310
+ onKillResumeMemory: null,
308
311
  onKillResume: null,
309
312
  recoveryCommand: null,
310
313
  onKillResumeDelay: null,
@@ -331,6 +334,9 @@ describe('buildDockerRuntimeMetadata', () => {
331
334
  networks: ['hive-formal-ai', 'public-egress'],
332
335
  networkAliases: ['formal-ai', 'checker'],
333
336
  resourceLimits: null,
337
+ resolvedLimits: null,
338
+ resourceLimitSpecs: null,
339
+ onKillResumeMemory: null,
334
340
  onKillResume: null,
335
341
  recoveryCommand: null,
336
342
  onKillResumeDelay: null,