start-command 0.37.9 → 0.39.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 (78) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -2
  3. package/bunfig.toml +4 -0
  4. package/eslint.config.mjs +1 -0
  5. package/package.json +4 -4
  6. package/src/bin/cli.js +62 -26
  7. package/src/lib/args-parser-queries.js +45 -1
  8. package/src/lib/args-parser.js +19 -0
  9. package/src/lib/cgroup-memory.js +95 -23
  10. package/src/lib/command-builder.js +4 -0
  11. package/src/lib/container-removal.js +4 -5
  12. package/src/lib/detached-finalize.js +1 -0
  13. package/src/lib/docker-command-handoff.js +34 -6
  14. package/src/lib/docker-init-option.js +65 -0
  15. package/src/lib/docker-inspect-batch.js +176 -0
  16. package/src/lib/docker-resource-options.js +21 -2
  17. package/src/lib/docker-runtime-args.js +19 -3
  18. package/src/lib/execution-attempt.js +19 -9
  19. package/src/lib/execution-chain.js +182 -0
  20. package/src/lib/execution-control.js +8 -6
  21. package/src/lib/execution-env.js +120 -0
  22. package/src/lib/execution-identity.js +180 -0
  23. package/src/lib/execution-recovery.js +21 -1
  24. package/src/lib/execution-resume.js +21 -1
  25. package/src/lib/execution-store.js +30 -23
  26. package/src/lib/exit-evidence.js +139 -7
  27. package/src/lib/isolation.js +21 -3
  28. package/src/lib/launch-identity-options.js +81 -0
  29. package/src/lib/launch-identity.js +411 -0
  30. package/src/lib/log-uploader.js +15 -1
  31. package/src/lib/output-blocks.js +11 -0
  32. package/src/lib/query-commands.js +18 -3
  33. package/src/lib/resume-resources.js +2 -2
  34. package/src/lib/screen-isolation.js +11 -2
  35. package/src/lib/spawn-helpers.js +33 -16
  36. package/src/lib/status-formatter.js +39 -131
  37. package/src/lib/status-probe.js +149 -0
  38. package/src/lib/store-decode-cache.js +141 -0
  39. package/src/lib/usage.js +11 -1
  40. package/test/args-parser-attach-resume.js +1 -1
  41. package/test/args-parser-control.js +2 -2
  42. package/test/cargo-lock-version.mjs +121 -0
  43. package/test/check-rust-coverage.mjs +71 -0
  44. package/test/check-web-archive.mjs +154 -0
  45. package/test/ci-workflow-invariants.js +204 -0
  46. package/test/command-builder-docker-cleanup.js +24 -0
  47. package/test/command-exit-codes.mjs +91 -0
  48. package/test/docker-runtime-options.js +2 -0
  49. package/test/docker-snapshot-safety.js +39 -0
  50. package/test/execution-resume.js +2 -0
  51. package/test/fixtures/fake-npm.sh +64 -0
  52. package/test/fixtures/lychee-report.md +39 -0
  53. package/test/javascript-first-ci.js +69 -0
  54. package/test/links-workflow.mjs +127 -0
  55. package/test/lint-gate.mjs +56 -0
  56. package/test/npm-publish.mjs +181 -0
  57. package/test/recheck-broken-links.mjs +452 -0
  58. package/test/regression-176.js +2 -0
  59. package/test/regression-187.js +2 -0
  60. package/test/regression-191.js +4 -0
  61. package/test/regression-193.js +2 -0
  62. package/test/regression-198.js +2 -0
  63. package/test/regression-207.js +8 -2
  64. package/test/regression-224.js +2 -0
  65. package/test/regression-229.js +267 -0
  66. package/test/regression-230.js +234 -0
  67. package/test/regression-231.js +298 -0
  68. package/test/regression-236.js +250 -0
  69. package/test/regression-238.js +129 -0
  70. package/test/regression-241.js +254 -0
  71. package/test/regression-242.js +241 -0
  72. package/test/regression-243.js +493 -0
  73. package/test/regression-244.js +471 -0
  74. package/test/release-push-retry.mjs +172 -0
  75. package/test/run-js-tests.mjs +15 -3
  76. package/test/setup/private-snapshot-lock.js +17 -0
  77. package/test/shared-golden.js +44 -2
  78. package/test/transient-links.mjs +139 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # start-command
2
2
 
3
+ ## 0.39.0
4
+
5
+ ### Minor Changes
6
+
7
+ - CI/CD no longer reports false results (#234). A rejected `npm publish` or any failing release command now fails the release instead of printing success, and the published version is confirmed on the registry: npm gets up to 20 minutes to show it, and a "cannot publish over" conflict is read as "already landed" rather than retried. A release push that loses the race for `main` is rebased and retried. Releases wait for every check their stage requires. A failing Rust test is reported as a failed test rather than as low coverage. `bun test -t <pattern>` keeps the 30s default timeout. Snapshot-resume tests in both languages use a private snapshot lock. The link checker fails on every broken live link, re-checks transient 429/5xx answers, persists its cache and uploads its report. ESLint warnings fail CI and the pre-commit hook. The changeset PR is opened with `RELEASE_PR_TOKEN` so its checks run. Step outputs reach `run:` blocks through `env`, tool versions are pinned, path-filtered workflows re-run when their scripts change, and the npm-publish tests no longer reach the real npm on macOS.
8
+
9
+ Support Docker `--memory-reservation` with sizes, daemon percentages and percentage ranges. Validate the soft cap against the effective hard memory limit, record it in status, and preserve or override it across resumes and recovery.
10
+
11
+ Reduce Docker cgroup sampler polling with a configurable five-second default, one namespace lookup per sampler, and local cgroup liveness checks. Set `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS` to a positive whole number of seconds to override the default for attached and detached runs.
12
+
13
+ Main pushes publish again (#240). Since #234 the Release job was skipped on every push to `main`: at job level the wildcard `needs.*.result` list also saw the deliberately skipped changeset check behind `test`/`coverage`. Release now compares each needed job by name, and the JavaScript Stage prints every term of the release condition and fails a main push whose release was skipped although all of its needs succeeded.
14
+
15
+ Attribute a Docker daemon restart without the host journal (#241). With a mounted `docker.sock` (Docker-outside-of-Docker) or a remote daemon, an exit-137 task is now reported as `killed (docker daemon restart)` when another container finished within the 15 s shutdown window with exit 137/143, or was started again after it, and was not OOM-killed. `exitEvidence.daemonRestartSource` records whether the host journal or the Docker API supported the attribution, and `--status` collects the evidence itself when the detached watcher died with the daemon before it could log any.
16
+
17
+ Sample Docker cgroup counters through one long-lived `docker exec -i` per run when the cgroup is only readable through the daemon (a mounted `docker.sock`). The host paces it with one tick per interval on stdin, so the container runs only shell builtins and the stream ends with the sampler; the per-round exec stays as a fallback. Docker is asked for `{{.State.Running}}` only after a round without a sample, and `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off` disables sampling.
18
+
19
+ `$ --status` and `$ --list` cost far less with many records (#243). The store is decoded once per content within a process, and a JSON copy of the decoded records next to `executions.lino` (tagged with the store's SHA-256) lets the next process skip the decode entirely. One `docker inspect --type container` now answers every docker probe of a query instead of one or two processes per record, and records whose container is already marked removed are no longer probed. Output is unchanged; probes the batch cannot answer (an old or stubbed `docker`) run one by one as before.
20
+
21
+ `--init` / `--no-init` run Docker's bundled tini (`docker run --init`) as PID 1 for docker isolation, so orphaned processes of a long-running task are reaped instead of piling up as zombies (#244). The default stays off; `START_COMMAND_DOCKER_INIT=1` turns it on for every docker launch on a host, and `--no-init` overrides that. The choice is stored with the record, shown as an `init` row in the start block and in `--status`, and carried into `--resume` snapshots and stacked launches; records from before keep running without it. Exit codes and OOM evidence are unchanged, but `--stop` (`docker stop`) now ends a command without a SIGTERM handler with 143 at once instead of 137 after the grace period.
22
+
23
+ ## 0.38.0
24
+
25
+ ### Minor Changes
26
+
27
+ - 8e62079: Executions now carry a stable identity a supervisor can rely on. A launch with an explicit `--session-id` that already exists is refused with exit 1 (`execution <uuid> already exists; use $ --resume <uuid> -- <command> to continue it, or pass --append-attempt`) instead of replacing the record and truncating its log; `--append-attempt` keeps the record, archives the previous attempt in `attemptHistory` and appends the new attempt to the same log (#229). Every backend (direct, `--isolated-user`, screen, tmux, docker, ssh, and docker resumes of stopped containers) exports `START_EXECUTION_ID`, `START_SESSION_NAME` and `START_ATTEMPT` to the command it runs (#230). `$ --alias <id> <name>` adds a name and `$ --rename <id> <name>` sets the display name of an execution, `--continues <id>` records the execution a fresh launch continues, `--status` reports `aliases` and the continuation `chain`, and `--upload-log` uploads the logs of the whole chain, oldest first (#231).
28
+
3
29
  ## 0.37.9
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -29,6 +29,9 @@ $ -i docker -d --label task.tool=codex -- my-task
29
29
  $ --attach <id>
30
30
  $ --resume <id> -- <command>
31
31
  $ --resume-all
32
+ $ --session-id <uuid> --append-attempt -- <command>
33
+ $ --continues <id> -- <command>
34
+ $ --rename <id> <name>
32
35
  ```
33
36
 
34
37
  See the project-wide [README](../README.md), [docs/USAGE.md](../docs/USAGE.md),
@@ -70,9 +73,11 @@ Released into the public domain under the [Unlicense](../LICENSE).
70
73
 
71
74
  ### Docker resource and recovery controls
72
75
 
73
- Launch with `--memory 64m`, `--memory-swap 64m` and `--cpus 1`, or use daemon
76
+ Launch with `--memory 64m`, `--memory-swap 64m`, `--memory-reservation 32m` and `--cpus 1`, or use daemon
74
77
  percentages and uniform ranges such as `--memory '70%-80%'`. Memory-swap defaults
75
- to memory. `--cpu-penalty` enables delayed caps and reports state through status.
78
+ to memory. Reservation is a soft cap and must be below the hard memory cap when
79
+ one is set. It accepts the same sizes and percentages, is shown in `--status`,
80
+ and survives resumes. `--cpu-penalty` enables delayed caps and reports state through status.
76
81
  Manual `--resume <id> --memory 128m` updates before starting;
77
82
  `--on-kill-resume-memory` changes memory only after a fresh qualifying OOM.
78
83
  See the [full resource and recovery guide](../README.md#docker-launch-limits-recovery-limits-and-cpu-penalty)
package/bunfig.toml CHANGED
@@ -1,3 +1,7 @@
1
1
  # Bun configuration
2
2
  # Tests use repository naming without conventional suffixes, so package scripts pass
3
3
  # the test files explicitly instead of relying on Bun's default discovery.
4
+
5
+ [test]
6
+ # Every test process gets a private Docker snapshot lock (issue #234).
7
+ preload = ["./test/setup/private-snapshot-lock.js"]
package/eslint.config.mjs CHANGED
@@ -25,6 +25,7 @@ export default [
25
25
  process: 'readonly',
26
26
  Buffer: 'readonly',
27
27
  fetch: 'readonly',
28
+ structuredClone: 'readonly',
28
29
  Bun: 'readonly',
29
30
  },
30
31
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.37.9",
3
+ "version": "0.39.0",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -12,12 +12,12 @@
12
12
  },
13
13
  "scripts": {
14
14
  "test": "bun ../scripts/run-js-tests.mjs",
15
- "lint": "eslint .",
15
+ "lint": "eslint . --max-warnings 0",
16
16
  "lint:fix": "eslint . --fix",
17
17
  "format": "prettier --write .",
18
18
  "format:check": "prettier --check .",
19
19
  "check:file-size": "bun ../scripts/check-file-size.mjs",
20
- "lint:scripts": "cd .. && eslint .",
20
+ "lint:scripts": "cd .. && eslint . --max-warnings 0",
21
21
  "format:check:scripts": "cd .. && prettier --check \"scripts/**/*.{js,mjs,cjs}\"",
22
22
  "check": "bun run lint && bun run lint:scripts && bun run format:check && bun run format:check:scripts && bun run check:file-size",
23
23
  "prepare": "cd .. && husky .husky || true",
@@ -58,7 +58,7 @@
58
58
  },
59
59
  "lint-staged": {
60
60
  "*.{js,mjs,cjs}": [
61
- "eslint --fix --max-warnings 6",
61
+ "eslint --fix --max-warnings 0 --no-warn-ignored",
62
62
  "prettier --write",
63
63
  "prettier --check"
64
64
  ],
package/src/bin/cli.js CHANGED
@@ -18,7 +18,6 @@ const {
18
18
  getTimestamp,
19
19
  createLogHeader,
20
20
  createLogFooter,
21
- writeLogFile,
22
21
  appendLogFile,
23
22
  createLogPath,
24
23
  getDefaultDockerImage,
@@ -41,6 +40,7 @@ const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
41
40
  const { resolveChildExitCode } = require('../lib/exit-reason');
42
41
  const { printUsage } = require('../lib/usage');
43
42
  const { buildDisplayCommand, getCommandName } = require('../lib/shell-utils');
43
+ const launchIdentity = require('../lib/launch-identity');
44
44
 
45
45
  // A closed stdout/stderr pipe (`$ --version | head -1`) must neither crash
46
46
  // start nor replace the command's exit code: drop the failed output and keep
@@ -245,6 +245,8 @@ const useCommandStream =
245
245
 
246
246
  // Generate session ID if not provided (auto-generate UUID)
247
247
  const sessionId = wrapperOptions.sessionId || generateUUID();
248
+ // How this launch uses its execution id (issue #229), set before dispatch
249
+ let launch = null;
248
250
 
249
251
  // Main execution
250
252
  (async () => {
@@ -256,6 +258,19 @@ const sessionId = wrapperOptions.sessionId || generateUUID();
256
258
  process.exit(queryExitCode === null ? 0 : queryExitCode);
257
259
  }
258
260
 
261
+ // An explicit --session-id must not overwrite a tracked execution (#229).
262
+ try {
263
+ launch = launchIdentity.prepareLaunch(
264
+ getExecutionStore(),
265
+ wrapperOptions,
266
+ sessionId,
267
+ createLogPath(wrapperOptions.isolated || 'direct', sessionId)
268
+ );
269
+ } catch (error) {
270
+ console.error(`Error: ${error.message}`);
271
+ process.exit(1);
272
+ }
273
+
259
274
  if (wrapperOptions.isolated === 'docker') {
260
275
  const {
261
276
  resolveResourceOptions,
@@ -314,8 +329,8 @@ async function runWithIsolation(
314
329
  effectiveImage = getDefaultDockerImage();
315
330
  }
316
331
 
317
- // Create log file path
318
- const logFilePath = createLogPath(environment || 'direct', sessionId);
332
+ // Create log file path (an appended attempt keeps the execution's log)
333
+ const logFilePath = launch.logPath;
319
334
 
320
335
  // Get session name (will be generated by runIsolated if not provided)
321
336
  const sessionName =
@@ -424,6 +439,7 @@ async function runWithIsolation(
424
439
  useCommandStream,
425
440
  },
426
441
  });
442
+ launchIdentity.applyLaunch(executionRecord, launch);
427
443
  }
428
444
 
429
445
  // Print start block with session ID and isolation info
@@ -448,8 +464,13 @@ async function runWithIsolation(
448
464
  currentExecutionRecord = executionRecord;
449
465
  require('../lib/launch-owner').markLaunch(executionRecord);
450
466
  try {
451
- store.save(executionRecord);
467
+ launchIdentity.claimLaunch(store, executionRecord, launch);
452
468
  } catch (err) {
469
+ if (err instanceof launchIdentity.LaunchRefusedError) {
470
+ console.error(`Error: ${err.message}`);
471
+ currentExecutionRecord = null;
472
+ process.exit(1);
473
+ }
453
474
  console.error(
454
475
  JSON.stringify({
455
476
  code: 'LAUNCH_RESERVATION_FAILED',
@@ -483,7 +504,12 @@ async function runWithIsolation(
483
504
  );
484
505
  }
485
506
 
486
- writeLogFile(logFilePath, logContent);
507
+ launchIdentity.writeLaunchLog(
508
+ logFilePath,
509
+ logContent,
510
+ launch,
511
+ executionRecord
512
+ );
487
513
 
488
514
  let result;
489
515
 
@@ -495,6 +521,7 @@ async function runWithIsolation(
495
521
  ...options,
496
522
  session: sessionName,
497
523
  sessionId,
524
+ attemptNumber: launch.attemptNumber,
498
525
  image: effectiveImage,
499
526
  endpoint: options.endpoint,
500
527
  detached: mode === 'detached',
@@ -513,7 +540,10 @@ async function runWithIsolation(
513
540
  });
514
541
  } else if (createdUser) {
515
542
  // Run directly as the created user (no isolation environment)
516
- result = await runAsIsolatedUser(cmd, createdUser);
543
+ result = await runAsIsolatedUser(
544
+ launchIdentity.isolatedUserCommand(cmd, sessionId, launch),
545
+ createdUser
546
+ );
517
547
  } else {
518
548
  // This shouldn't happen in isolation mode, but handle gracefully
519
549
  result = { success: false, message: 'No isolation configuration provided' };
@@ -637,8 +667,8 @@ async function runDirect(cmd, sessionId) {
637
667
  const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
638
668
  const shellArgs = isWindows ? ['-Command', cmd] : ['-c', cmd];
639
669
 
640
- // Setup logging
641
- const logFilePath = createLogPath('direct', sessionId);
670
+ // Setup logging (an appended attempt keeps the execution's log)
671
+ const logFilePath = launch.logPath;
642
672
 
643
673
  let logContent = '';
644
674
  const startTime = getTimestamp();
@@ -666,6 +696,8 @@ async function runDirect(cmd, sessionId) {
666
696
  runtimeVersion,
667
697
  },
668
698
  });
699
+ launchIdentity.applyLaunch(executionRecord, launch);
700
+ launchIdentity.claimDirectLaunch(store, executionRecord, launch, config);
669
701
  // Set global reference for signal cleanup
670
702
  currentExecutionRecord = executionRecord;
671
703
  }
@@ -746,7 +778,13 @@ async function runDirect(cmd, sessionId) {
746
778
  shell,
747
779
  shellArgs,
748
780
  logFilePath,
749
- logContent,
781
+ logContent: launchIdentity.launchLogContent(
782
+ logContent,
783
+ launch,
784
+ executionRecord
785
+ ),
786
+ appendLog: launch.append,
787
+ env: launchIdentity.directLaunchEnv(sessionId, launch),
750
788
  startTimeMs,
751
789
  executionRecord,
752
790
  store,
@@ -786,8 +824,8 @@ async function runDirectWithCommandStream(
786
824
  const isWindows = process.platform === 'win32';
787
825
  const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
788
826
 
789
- // Setup logging
790
- const logFilePath = createLogPath('direct', sessionId);
827
+ // Setup logging (an appended attempt keeps the execution's log)
828
+ const logFilePath = launch.logPath;
791
829
 
792
830
  let logContent = '';
793
831
  const startTime = getTimestamp();
@@ -816,6 +854,8 @@ async function runDirectWithCommandStream(
816
854
  executionMode: 'command-stream',
817
855
  },
818
856
  });
857
+ launchIdentity.applyLaunch(executionRecord, launch);
858
+ launchIdentity.claimDirectLaunch(store, executionRecord, launch, config);
819
859
  // Set global reference for signal cleanup
820
860
  currentExecutionRecord = executionRecord;
821
861
  }
@@ -853,27 +893,23 @@ async function runDirectWithCommandStream(
853
893
  console.log('[command-stream] Using command-stream library');
854
894
  console.log('');
855
895
 
856
- // Save initial execution record (PID will be updated later if available)
857
- if (executionRecord && store) {
858
- try {
859
- store.save(executionRecord);
860
- } catch (err) {
861
- if (config.verbose) {
862
- console.error(
863
- `[Tracking] Warning: Could not save execution record: ${err.message}`
864
- );
865
- }
866
- }
867
- }
868
-
869
896
  // Execute the command using command-stream with real-time output
870
897
  // Using mirror: true to show output in real-time, capture: true to collect it
871
898
  // Using raw() to avoid auto-escaping that might interfere with complex shell commands
872
- const $cmd = $({ mirror: true, capture: true });
899
+ const $cmd = $({
900
+ mirror: true,
901
+ capture: true,
902
+ env: launchIdentity.directLaunchEnv(sessionId, launch),
903
+ });
873
904
 
874
905
  let exitCode;
875
906
  try {
876
- writeLogFile(logFilePath, logContent);
907
+ launchIdentity.writeLaunchLog(
908
+ logFilePath,
909
+ logContent,
910
+ launch,
911
+ executionRecord
912
+ );
877
913
  // Use raw() to pass the command without auto-escaping
878
914
  // This is important for complex commands with pipes, redirects, etc.
879
915
  const result = await $cmd`${raw(cmd)}`;
@@ -9,6 +9,7 @@
9
9
  * Control modes: --upload-log, --stop, --terminate, --attach [--read-only],
10
10
  * --resume [-- <command>], --resume-all,
11
11
  * --cleanup [<uuid-or-session-name>]
12
+ * Naming modes: --alias <id> <name>, --rename <id> <name> (issue #231)
12
13
  */
13
14
 
14
15
  /**
@@ -39,6 +40,9 @@ function createQueryOptionDefaults() {
39
40
  cleanup: false, // Clean up stale "executing" records
40
41
  cleanupDryRun: false, // Show what would be cleaned without cleaning
41
42
  cleanupTarget: null, // UUID/session name whose containers and images --cleanup removes
43
+ alias: null, // Execution that --alias adds a name to
44
+ rename: null, // Execution that --rename renames
45
+ newName: null, // Name given by --alias/--rename
42
46
  };
43
47
  }
44
48
 
@@ -120,6 +124,35 @@ function hasValue(value) {
120
124
  return value !== null && value !== undefined;
121
125
  }
122
126
 
127
+ /**
128
+ * `--alias <id> <name>` and `--rename <id> <name>` take two arguments.
129
+ */
130
+ const NAMING_OPTIONS = {
131
+ '--alias': 'alias',
132
+ '--rename': 'rename',
133
+ };
134
+
135
+ function parseNamingOption(args, index, options) {
136
+ const key = NAMING_OPTIONS[args[index]];
137
+ if (!key) {
138
+ return 0;
139
+ }
140
+ const [identifier, name] = args.slice(index + 1, index + 3);
141
+ if (
142
+ identifier === undefined ||
143
+ name === undefined ||
144
+ identifier.startsWith('-') ||
145
+ name.startsWith('-')
146
+ ) {
147
+ throw new Error(
148
+ `Option ${args[index]} requires a UUID or session name and a new name`
149
+ );
150
+ }
151
+ options[key] = identifier;
152
+ options.newName = name;
153
+ return 3;
154
+ }
155
+
123
156
  /**
124
157
  * Parse a single query/control option.
125
158
  * @param {string[]} args - Arguments array
@@ -135,6 +168,11 @@ function parseQueryOption(args, index, options) {
135
168
  return cleanupConsumed;
136
169
  }
137
170
 
171
+ const namingConsumed = parseNamingOption(args, index, options);
172
+ if (namingConsumed > 0) {
173
+ return namingConsumed;
174
+ }
175
+
138
176
  const flag = FLAG_OPTIONS[arg];
139
177
  if (flag) {
140
178
  flag(options);
@@ -216,6 +254,12 @@ function activeQueryModes(options) {
216
254
  if (options.cleanup) {
217
255
  modes.push('--cleanup');
218
256
  }
257
+ if (hasValue(options.alias)) {
258
+ modes.push('--alias');
259
+ }
260
+ if (hasValue(options.rename)) {
261
+ modes.push('--rename');
262
+ }
219
263
  return modes;
220
264
  }
221
265
 
@@ -248,7 +292,7 @@ function validateQueryOptions(options) {
248
292
 
249
293
  if (modes.length > 1) {
250
294
  throw new Error(
251
- 'Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --cleanup in the same invocation'
295
+ 'Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename in the same invocation'
252
296
  );
253
297
  }
254
298
 
@@ -14,6 +14,7 @@
14
14
  * --mount <mount-spec> Docker --mount spec (repeatable, docker only)
15
15
  * --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
16
16
  * --privileged Run docker container in privileged mode (docker only)
17
+ * --init, --no-init Run an init process (tini) as PID 1 to reap orphans (docker only; default START_COMMAND_DOCKER_INIT)
17
18
  * --network <name> Connect docker container to a named network (repeatable, docker only)
18
19
  * --network-alias <alias> Add an alias to the first network (repeatable, docker only)
19
20
  * --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
@@ -39,6 +40,7 @@
39
40
  * --resume-all Re-attach or reconcile every execution still marked running
40
41
  * --cleanup [<uuid-or-session>] Clean up stale "executing" records, or one execution's containers and snapshot images
41
42
  * --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
43
+ * --alias <id> <name>, --rename <id> <name>, --append-attempt, --continues <id>: see args-parser-queries.js, launch-identity-options.js
42
44
  */
43
45
 
44
46
  const { getDefaultDockerImage } = require('./docker-utils');
@@ -46,7 +48,9 @@ const dockerNetworkOptions = require('./docker-network-options');
46
48
  const dockerRecoveryOptions = require('./docker-recovery-options');
47
49
  const cpuPenalty = require('./cpu-penalty');
48
50
  const dockerResourceOptions = require('./docker-resource-options');
51
+ const dockerInitOption = require('./docker-init-option');
49
52
  const snapshotResumeOptions = require('./snapshot-resume-options');
53
+ const launchIdentityOptions = require('./launch-identity-options');
50
54
  const { parseSequence, isSequence } = require('./sequence-parser');
51
55
  const { buildCommandString } = require('./shell-utils');
52
56
  const {
@@ -197,6 +201,7 @@ function parseArgs(args) {
197
201
  labels: [], // Caller attribution, applied to every Docker level
198
202
  help: false,
199
203
  privileged: false, // Run docker container in privileged mode
204
+ init: null, // docker run --init; null until resolved from START_COMMAND_DOCKER_INIT (issue #244)
200
205
  network: null, // First Docker network name (compatibility accessor)
201
206
  networks: [], // Ordered Docker network names
202
207
  networkAliases: [], // Docker network-scoped aliases
@@ -217,6 +222,7 @@ function parseArgs(args) {
217
222
  useCommandStream: false, // Use command-stream library for command execution
218
223
  ...createQueryOptionDefaults(),
219
224
  ...snapshotResumeOptions.createSnapshotOptionDefaults(),
225
+ ...launchIdentityOptions.createLaunchIdentityOptionDefaults(),
220
226
  };
221
227
 
222
228
  let commandArgs = [];
@@ -427,6 +433,11 @@ function parseOption(args, index, options) {
427
433
  return 1;
428
434
  }
429
435
 
436
+ // --init / --no-init (for docker, issue #244)
437
+ if (dockerInitOption.parse(args, index, options)) {
438
+ return 1;
439
+ }
440
+
430
441
  const networkOption = dockerNetworkOptions.parse(args, index, options);
431
442
  if (networkOption) {
432
443
  return networkOption;
@@ -580,6 +591,12 @@ function parseOption(args, index, options) {
580
591
  return queryConsumed;
581
592
  }
582
593
 
594
+ // --append-attempt, --continues <id> (issues #229, #231)
595
+ const identityConsumed = launchIdentityOptions.parse(args, index, options);
596
+ if (identityConsumed > 0) {
597
+ return identityConsumed;
598
+ }
599
+
583
600
  // Not a recognized wrapper option
584
601
  return 0;
585
602
  }
@@ -794,6 +811,7 @@ function validateOptions(options) {
794
811
 
795
812
  dockerRecoveryOptions.validate(options);
796
813
  dockerResourceOptions.validate(options);
814
+ dockerInitOption.validate(options);
797
815
  cpuPenalty.validate(options);
798
816
 
799
817
  // Session name is only valid with isolation
@@ -830,6 +848,7 @@ function validateOptions(options) {
830
848
 
831
849
  snapshotResumeOptions.validate(options);
832
850
  validateQueryOptions(options);
851
+ launchIdentityOptions.validate(options);
833
852
 
834
853
  // Validate shell option
835
854
  if (options.shell !== null && options.shell !== undefined) {
@@ -27,8 +27,8 @@
27
27
 
28
28
  const { shellQuote } = require('./isolation-log-utils');
29
29
 
30
- /** Seconds between two samples of a running container's cgroup. */
31
- const CGROUP_SAMPLE_INTERVAL_SECONDS = 1;
30
+ /** Default seconds between samples; the watcher environment may override it. */
31
+ const CGROUP_SAMPLE_INTERVAL_SECONDS = 5;
32
32
 
33
33
  /** Shell variables used by the sampler snippets. */
34
34
  const CGROUP_SHELL_VARS = {
@@ -74,10 +74,50 @@ function buildCgroupFunctionsSnippet() {
74
74
  ].join('; ');
75
75
  }
76
76
 
77
+ /**
78
+ * In-container loop of the long-lived sampler exec (issue #242): `$1` is the
79
+ * cgroup directory. The host paces it with one newline per interval on stdin
80
+ * (`docker exec -i`), so it runs only shell builtins: the task's own CPU
81
+ * limit pays for no `cat`, `sed` or `sleep` processes. Stdin reaches EOF when
82
+ * the exec's client is gone, so the loop never outlives the sampler; dockerd
83
+ * would accept its output even then. Exit 3: unreadable counters.
84
+ * @returns {string} Shell script for `sh -c`
85
+ */
86
+ function buildCgroupStreamScript() {
87
+ return [
88
+ 'while read -r __scx_tick; do __scx_oom=; __scx_kill=; __scx_max=-; __scx_peak=-',
89
+ '[ -r "$1/memory.events" ] || exit 3',
90
+ 'while read -r __scx_key __scx_value; do case "$__scx_key" in ' +
91
+ 'oom) __scx_oom=$__scx_value;; oom_kill) __scx_kill=$__scx_value;; esac; ' +
92
+ 'done < "$1/memory.events"',
93
+ 'case "$__scx_oom:$__scx_kill" in *[!0-9:]*|:*|*:) exit 3;; esac',
94
+ '[ ! -r "$1/memory.max" ] || read -r __scx_max < "$1/memory.max"',
95
+ '[ ! -r "$1/memory.peak" ] || read -r __scx_peak < "$1/memory.peak"',
96
+ 'printf \'%s %s %s %s %s\\n\' "${__scx_max:--}" "${__scx_peak:--}" ' +
97
+ '"$__scx_oom" "$__scx_kill" "$1" || exit 0; done',
98
+ ].join('; ');
99
+ }
100
+
101
+ /** `$0` of the long-lived exec, visible in `ps` inside the task container. */
102
+ const CGROUP_STREAM_NAME = 'start-command-cgroup-stream';
103
+
104
+ /** Interval value that turns the sampler off (issue #242). */
105
+ const CGROUP_SAMPLING_OFF_REASON =
106
+ 'sampling disabled by START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off';
107
+
77
108
  /**
78
109
  * Shell fragment starting the background sampler. It writes each sample to a
79
110
  * temp file with `mv`, so the watcher never reads a half-written line, and
80
- * ends by itself once the cgroup is gone.
111
+ * ends by itself once the container is gone.
112
+ *
113
+ * Where the cgroup is readable locally it reads it directly. Otherwise, with
114
+ * a private cgroup namespace (a mounted docker.sock), one long-lived
115
+ * `docker exec -i` answers one host tick per interval (issue #242); a
116
+ * successful read proves the container runs, so Docker is asked for liveness
117
+ * only after a failed round or when the stream ends. Without a usable stream it falls back
118
+ * to one `docker exec` per round; without `sh` in the image (exit 126/127) or
119
+ * without any readable cgroup it records the reason and stops.
120
+ * `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off` disables sampling.
81
121
  * @param {string} containerName - Container to sample
82
122
  * @returns {string} Shell command
83
123
  */
@@ -85,32 +125,61 @@ function buildCgroupSamplerStartSnippet(containerName) {
85
125
  const v = CGROUP_SHELL_VARS;
86
126
  const name = shellQuote(containerName);
87
127
  const remoteRead = '__start_command_cgroup_read /sys/fs/cgroup';
128
+ const unavailable = (detail) =>
129
+ `printf '%s\\n' "local cgroup unavailable; ${detail}" > "$${v.file}.reason"`;
130
+ const execReason = unavailable(
131
+ 'Docker exec exit=$__scs_exit: ${__scs_detail:-private cgroup v2 counters unreadable}'
132
+ );
88
133
  return [
89
134
  buildCgroupFunctionsSnippet(),
90
135
  `${v.file}="\${TMPDIR:-/tmp}/start-command-cgroup.$$"`,
91
136
  `rm -f "$${v.file}" "$${v.file}.tmp" "$${v.file}.reason"`,
92
137
  `__start_command_memory_limit=$(docker inspect -f '{{.HostConfig.Memory}}' ${name} 2>/dev/null)`,
93
138
  `__start_command_sample_name=${name}`,
94
- // The first counter is a baseline, including after watcher restart. Only
95
- // changes between adjacent successful samples supply exit-time evidence.
96
- `( export START_COMMAND_CGROUP_SAMPLING=1; __scs_previous=''; __scs_previous_at=0; __scs_changed=0; ` +
97
- `__scs_dir=$(__start_command_cgroup_dir ${name}); while :; do ` +
98
- '__scs_line=""; if [ -n "$__scs_dir" ]; then __scs_line=$(__start_command_cgroup_read "$__scs_dir"); fi; ' +
99
- `if [ -z "$__scs_line" ]; then __scs_ns=$(docker inspect -f '{{.HostConfig.CgroupnsMode}}' ${name} 2>/dev/null); ` +
100
- `if [ "$__scs_ns" = private ]; then ` +
101
- `__scs_remote=$(docker exec ${name} sh -c ${shellQuote(`${buildCgroupFunctionsSnippet()}; ${remoteRead}`)} 2>&1); __scs_exit=$?; ` +
102
- 'if [ "$__scs_exit" = 0 ]; then __scs_line=$__scs_remote; ' +
103
- 'else __scs_detail=$(printf "%s" "$__scs_remote" | sed -n "1p" | cut -c 1-240); ' +
104
- `printf '%s\n' "local cgroup unavailable; Docker exec exit=$__scs_exit: \${__scs_detail:-private cgroup v2 counters unreadable}" > "$${v.file}.reason"; fi; ` +
105
- `else printf '%s\n' "local cgroup unavailable; container cgroup namespace=\${__scs_ns:-unavailable}, private required" > "$${v.file}.reason"; fi; fi; ` +
106
- '__scs_now=$(date +%s); if [ -n "$__scs_line" ]; then ' +
107
- '__scs_kill=$(printf "%s" "$__scs_line" | cut -d " " -f 4); ' +
108
- 'if [ -n "$__scs_previous" ] && [ "$__scs_kill" -gt "$__scs_previous" ] 2>/dev/null && [ "$((__scs_now-__scs_previous_at))" -le 3 ]; then __scs_changed=$__scs_now; fi; ' +
109
- '__scs_previous=$__scs_kill; __scs_previous_at=$__scs_now; ' +
110
- '__scs_fields=$(printf "%s" "$__scs_line" | cut -d " " -f 1-4); ' +
111
- `printf '%s %s %s %s\\n' "$__scs_fields" "$__scs_changed" "$__scs_now" "$__scs_dir" > "$${v.file}.tmp" && mv -f "$${v.file}.tmp" "$${v.file}"; fi; ` +
112
- `if [ "$(docker inspect -f '{{.State.Running}}' ${name} 2>/dev/null)" = false ]; then break; fi; ` +
113
- `sleep ${CGROUP_SAMPLE_INTERVAL_SECONDS}; done ) >/dev/null 2>&1 & ${v.sampler}=$!`,
139
+ `( export START_COMMAND_CGROUP_SAMPLING=1; ` +
140
+ // Validate in the watcher, so attached and detached launches inherit the
141
+ // same setting. Never allow a malformed/zero sleep to create a busy loop.
142
+ `__scs_interval=\${START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS:-${CGROUP_SAMPLE_INTERVAL_SECONDS}}; ` +
143
+ `case "$__scs_interval" in [Oo][Ff][Ff]) printf '%s\\n' '${CGROUP_SAMPLING_OFF_REASON}' > "$${v.file}.reason"; exit 0;; ` +
144
+ `*[!0-9]*|'') __scs_interval=${CGROUP_SAMPLE_INTERVAL_SECONDS};; esac; ` +
145
+ `[ "$__scs_interval" -gt 0 ] 2>/dev/null || __scs_interval=${CGROUP_SAMPLE_INTERVAL_SECONDS}; ` +
146
+ // Store one `<max> <peak> <oom> <oom_kill>` line. The previous sample is
147
+ // read back from the file, so the stream's reader subshell can record
148
+ // too. The first counter is a baseline, including after watcher restart;
149
+ // only changes between adjacent samples supply exit-time evidence.
150
+ `__scs_record() { set -f; set -- $1; set +f; case "$3:$4" in *[!0-9:]*|:*|*:) return 1;; esac; ` +
151
+ `__scs_now=$(date +%s); __scs_previous=''; __scs_changed=0; __scs_previous_at=0; ` +
152
+ `[ ! -r "$${v.file}" ] || read -r __scs_p __scs_p __scs_p __scs_previous __scs_changed __scs_previous_at __scs_p < "$${v.file}"; ` +
153
+ 'if [ -n "$__scs_previous" ] && [ "$4" -gt "$__scs_previous" ] 2>/dev/null && [ "$((__scs_now-__scs_previous_at))" -le 3 ]; then __scs_changed=$__scs_now; fi; ' +
154
+ `printf '%s %s %s %s %s %s %s\\n' "$1" "$2" "$3" "$4" "$__scs_changed" "$__scs_now" "$__scs_dir" > "$${v.file}.tmp" && mv -f "$${v.file}.tmp" "$${v.file}"; }; ` +
155
+ `__scs_dir=$(__start_command_cgroup_dir ${name}); ` +
156
+ // The namespace is fixed at container creation, including when reads fail.
157
+ `__scs_ns=$(docker inspect -f '{{.HostConfig.CgroupnsMode}}' ${name} 2>/dev/null); __scs_stream=1; __scs_reader=''; ` +
158
+ `while :; do __scs_ok=''; if [ -n "$__scs_dir" ]; then ` +
159
+ '__scs_line=$(__start_command_cgroup_read "$__scs_dir") && __scs_record "$__scs_line" && __scs_ok=1; ' +
160
+ `elif [ "$__scs_ns" != private ]; then ${unavailable('container cgroup namespace=${__scs_ns:-unavailable}, private required')}; break; ` +
161
+ `elif [ "$__scs_stream" = 1 ]; then ` +
162
+ // The reader ends with the stream; stopping the sampler stops it too.
163
+ `trap 'kill "$__scs_reader" 2>/dev/null; wait "$__scs_reader" 2>/dev/null; exit 0' TERM; ` +
164
+ // The ticker runs in the background, so a failed exec is reported at
165
+ // once; it ends on its first write after the exec is gone.
166
+ `{ { while :; do echo || exit 0; sleep "$__scs_interval" || exit 0; done 2>/dev/null & } | ` +
167
+ `docker exec -i ${name} sh -c ${shellQuote(buildCgroupStreamScript())} ${CGROUP_STREAM_NAME} /sys/fs/cgroup 2>&1; printf '__scs_exit %s\\n' "$?"; } | ` +
168
+ `{ __scs_got=''; __scs_exit=''; __scs_detail=''; while IFS= read -r __scs_line; do case "$__scs_line" in ` +
169
+ `'__scs_exit '*) __scs_exit=\${__scs_line#__scs_exit };; ` +
170
+ `*) if __scs_record "$__scs_line"; then __scs_got=1; elif [ -z "$__scs_detail" ]; then __scs_detail=$(printf '%s' "$__scs_line" | cut -c 1-240); fi;; esac; done; ` +
171
+ `[ -z "$__scs_got" ] || exit 0; ${execReason}; ` +
172
+ `case "$__scs_exit" in ''|0|*[!0-9]*) exit 1;; esac; exit "$__scs_exit"; } & ` +
173
+ `__scs_reader=$!; wait "$__scs_reader"; __scs_exit=$?; trap - TERM; __scs_reader=''; ` +
174
+ // No shell in the image: a per-round exec cannot succeed either.
175
+ `case "$__scs_exit" in 0) ;; 126|127) break;; *) __scs_stream=0;; esac; ` +
176
+ `else __scs_remote=$(docker exec ${name} sh -c ${shellQuote(`${buildCgroupFunctionsSnippet()}; ${remoteRead}`)} 2>&1); __scs_exit=$?; ` +
177
+ 'if [ "$__scs_exit" = 0 ] && __scs_record "$__scs_remote"; then __scs_ok=1; ' +
178
+ `else __scs_detail=$(printf "%s" "$__scs_remote" | sed -n "1p" | cut -c 1-240); ${execReason}; fi; fi; ` +
179
+ // A successful read needs no Docker liveness probe, and the completion
180
+ // watcher still stops us after docker wait.
181
+ `if [ -z "$__scs_ok" ] && [ "$(docker inspect -f '{{.State.Running}}' ${name} 2>/dev/null)" = false ]; then break; fi; ` +
182
+ `sleep "$__scs_interval"; done ) >/dev/null 2>&1 & ${v.sampler}=$!`,
114
183
  ].join('; ');
115
184
  }
116
185
 
@@ -305,13 +374,16 @@ function formatCgroupMemory(memory) {
305
374
 
306
375
  module.exports = {
307
376
  CGROUP_SAMPLE_INTERVAL_SECONDS,
377
+ CGROUP_SAMPLING_OFF_REASON,
308
378
  CGROUP_SHELL_VARS,
379
+ CGROUP_STREAM_NAME,
309
380
  OOM_SCOPE,
310
381
  OOM_SCOPE_NOTES,
311
382
  buildCgroupFunctionsSnippet,
312
383
  buildCgroupMemoryLogSnippet,
313
384
  buildCgroupSamplerStartSnippet,
314
385
  buildCgroupSamplerStopSnippet,
386
+ buildCgroupStreamScript,
315
387
  describeCgroupOomScope,
316
388
  formatCgroupMemory,
317
389
  formatCgroupMemoryLogLine,