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
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Continuation chains (issue #231): a launch with `--continues <id>` stores
3
+ * the UUID it continues, so a supervisor that relaunches fresh instead of
4
+ * `--resume`-ing in place can still treat every execution of one task as a
5
+ * whole. `--status` prints the chain and `--upload-log` uploads one file with
6
+ * every execution of it, oldest first.
7
+ */
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { executionAliases, hasUserNames } = require('./execution-identity');
12
+
13
+ /**
14
+ * UUIDs of the chain `record` belongs to, oldest first. Backward links come
15
+ * from `continues`; forward, the most recently started record that continues
16
+ * the current one is followed. Cycles stop the walk.
17
+ * @param {object[]} records - Stored records
18
+ * @param {object} record - Any member of the chain
19
+ * @returns {object[]} Records of the chain, oldest first
20
+ */
21
+ function buildChain(records, record) {
22
+ const seen = new Set([record.uuid]);
23
+ const chain = [record];
24
+ for (let cur = record; cur.continues; ) {
25
+ const prev = records.find((r) => r.uuid === cur.continues);
26
+ if (!prev || seen.has(prev.uuid)) {
27
+ break;
28
+ }
29
+ seen.add(prev.uuid);
30
+ chain.unshift(prev);
31
+ cur = prev;
32
+ }
33
+ for (let cur = record; ; ) {
34
+ const next = records
35
+ .filter((r) => r.continues === cur.uuid && !seen.has(r.uuid))
36
+ .sort((a, b) => Date.parse(b.startTime) - Date.parse(a.startTime))[0];
37
+ if (!next) {
38
+ break;
39
+ }
40
+ seen.add(next.uuid);
41
+ chain.push(next);
42
+ cur = next;
43
+ }
44
+ return chain;
45
+ }
46
+
47
+ /**
48
+ * Resolve `--continues <id>` for a new launch to the UUID it stores.
49
+ * @param {object|null} store - ExecutionStore
50
+ * @param {string} identifier - Any name of the earlier execution
51
+ * @param {string} sessionId - UUID of the new launch
52
+ * @returns {string} UUID of the continued execution
53
+ * @throws {Error} When it cannot be resolved
54
+ */
55
+ function resolveContinues(store, identifier, sessionId) {
56
+ if (!store) {
57
+ throw new Error('--continues requires execution tracking.');
58
+ }
59
+ const target = store.get(identifier);
60
+ if (!target) {
61
+ throw new Error(
62
+ `No execution found with UUID or session name: ${identifier}`
63
+ );
64
+ }
65
+ if (target.uuid === sessionId) {
66
+ throw new Error(`Execution ${sessionId} cannot continue itself`);
67
+ }
68
+ return target.uuid;
69
+ }
70
+
71
+ /**
72
+ * Wrap a status record so `toObject()` also reports the names it answers to
73
+ * (`aliases`, once `--alias`/`--rename` gave it one) and its continuation
74
+ * `chain`, right after `uuid`.
75
+ * @param {object} record - Record-like object with toObject()
76
+ * @param {object|null} store - ExecutionStore
77
+ * @returns {object} The record, or a wrapper with the extra fields
78
+ */
79
+ function attachIdentity(record, store) {
80
+ const aliases = hasUserNames(record) ? executionAliases(record) : null;
81
+ const chain =
82
+ store &&
83
+ (record.continues ||
84
+ store.getAll().some((r) => r.continues === record.uuid))
85
+ ? buildChain(store.getAll(), record).map((r) => r.uuid)
86
+ : null;
87
+ if (!aliases && !(chain && chain.length > 1)) {
88
+ return record;
89
+ }
90
+ const wrapped = Object.create(Object.getPrototypeOf(record));
91
+ Object.assign(wrapped, record);
92
+ const source = record.toObject.bind(record);
93
+ wrapped.toObject = function () {
94
+ const ordered = {};
95
+ for (const [key, value] of Object.entries(source())) {
96
+ ordered[key] = value;
97
+ if (key === 'uuid') {
98
+ if (aliases) {
99
+ ordered.aliases = aliases;
100
+ }
101
+ if (chain && chain.length > 1) {
102
+ ordered.chain = chain;
103
+ }
104
+ }
105
+ }
106
+ return ordered;
107
+ };
108
+ return wrapped;
109
+ }
110
+
111
+ /**
112
+ * Text-format lines for the identity fields of a status object.
113
+ * @param {object} obj - Output of toObject()
114
+ * @returns {string[]} Lines
115
+ */
116
+ function identityTextLines(obj) {
117
+ return [
118
+ ...(obj.aliases ? [`Aliases: ${obj.aliases.join(', ')}`] : []),
119
+ ...(obj.continues ? [`Continues: ${obj.continues}`] : []),
120
+ ...(obj.chain ? [`Chain: ${obj.chain.join(' -> ')}`] : []),
121
+ ];
122
+ }
123
+
124
+ /**
125
+ * Concatenate the logs of a chain into one temporary file, oldest first,
126
+ * each preceded by a lifecycle header in the format `--resume` writes.
127
+ * @param {object[]} chain - Records, oldest first
128
+ * @returns {{path: string, cleanup: Function}} Combined log
129
+ */
130
+ function writeChainLog(chain) {
131
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-chain-log-'));
132
+ const file = path.join(dir, `${chain[chain.length - 1].uuid}.chain.log`);
133
+ chain.forEach((record, index) => {
134
+ const header = {
135
+ event: 'continuation',
136
+ uuid: record.uuid,
137
+ chainIndex: index + 1,
138
+ chainLength: chain.length,
139
+ continues: record.continues || null,
140
+ command: record.command,
141
+ startTime: record.startTime,
142
+ logPath: record.logPath || null,
143
+ };
144
+ fs.appendFileSync(
145
+ file,
146
+ `\n[Start Command Lifecycle] ${JSON.stringify(header)}\n`
147
+ );
148
+ if (record.logPath && fs.existsSync(record.logPath)) {
149
+ fs.appendFileSync(file, fs.readFileSync(record.logPath));
150
+ } else {
151
+ fs.appendFileSync(file, `Log file not found: ${record.logPath}\n`);
152
+ }
153
+ });
154
+ return {
155
+ path: file,
156
+ cleanup: () => fs.rmSync(dir, { recursive: true, force: true }),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Log path `--upload-log` should upload for `record`: its own log, or a
162
+ * combined log when it belongs to a chain of more than one execution.
163
+ * @param {object} store - ExecutionStore
164
+ * @param {object} record - Resolved record
165
+ * @returns {{path: string, cleanup: Function}} Log to upload
166
+ */
167
+ function prepareUploadLog(store, record) {
168
+ const chain = buildChain(store.getAll(), record);
169
+ if (chain.length < 2) {
170
+ return { path: record.logPath, cleanup: () => {} };
171
+ }
172
+ return writeChainLog(chain);
173
+ }
174
+
175
+ module.exports = {
176
+ attachIdentity,
177
+ buildChain,
178
+ identityTextLines,
179
+ prepareUploadLog,
180
+ resolveContinues,
181
+ writeChainLog,
182
+ };
@@ -11,6 +11,7 @@ const {
11
11
  formatAsNestedLinksNotation,
12
12
  } = require('./output-blocks');
13
13
  const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
14
+ const { prefetchedInspect } = require('./docker-inspect-batch');
14
15
 
15
16
  const ControlAction = {
16
17
  STOP: 'stop',
@@ -148,14 +149,15 @@ function collectProcessIds(record, runner = runCommand) {
148
149
  ),
149
150
  ];
150
151
  addIfPresent(processIds, 'commandPids', commandPids);
152
+ } else if (isolated === 'docker' && record.containerState === 'removed') {
153
+ // The container is gone, so there is nothing to ask Docker (#243).
154
+ addIfPresent(processIds, 'containerId', opts.containerId);
151
155
  } else if (isolated === 'docker') {
152
156
  addIfPresent(processIds, 'containerId', opts.containerId);
153
- const result = runner(getDockerCommand(), [
154
- 'inspect',
155
- '-f',
156
- '{{.Id}} {{.State.Pid}}',
157
- sessionName,
158
- ]);
157
+ const format = '{{.Id}} {{.State.Pid}}';
158
+ const result =
159
+ prefetchedInspect(format, sessionName) ||
160
+ runner(getDockerCommand(), ['inspect', '-f', format, sessionName]);
159
161
  if (result.success && result.stdout.trim()) {
160
162
  const [containerId, pidValue] = result.stdout.trim().split(/\s+/);
161
163
  addIfPresent(processIds, 'containerId', containerId);
@@ -0,0 +1,120 @@
1
+ /**
2
+ * START_* variables that tell a launched command which execution it belongs
3
+ * to (issue #230). Every backend exports the same three variables, next to
4
+ * the kill-recovery `START_COMMAND_RECOVERY_ATTEMPT`:
5
+ *
6
+ * - START_EXECUTION_ID: the execution UUID (`--session-id`)
7
+ * - START_SESSION_NAME: the isolation session/container name (`--session`);
8
+ * empty for direct runs, which have no session
9
+ * - START_ATTEMPT: 1 for the first run, n for the n-th attempt (`--resume`,
10
+ * kill recovery or `--append-attempt`), the same number `--status` shows
11
+ */
12
+ const { shellQuote } = require('./isolation-log-utils');
13
+
14
+ const EXECUTION_ID_ENV = 'START_EXECUTION_ID';
15
+ const SESSION_NAME_ENV = 'START_SESSION_NAME';
16
+ const ATTEMPT_ENV = 'START_ATTEMPT';
17
+
18
+ /**
19
+ * Build the START_* variables for one attempt.
20
+ * @param {object} params - {executionId, sessionName, attempt}
21
+ * @returns {object} Variables in a stable order, or {} without an execution id
22
+ */
23
+ function buildExecutionEnv({ executionId, sessionName, attempt } = {}) {
24
+ if (!executionId) {
25
+ return {};
26
+ }
27
+ const number = Number(attempt);
28
+ return {
29
+ [EXECUTION_ID_ENV]: String(executionId),
30
+ [SESSION_NAME_ENV]: sessionName ? String(sessionName) : '',
31
+ [ATTEMPT_ENV]: String(
32
+ Number.isSafeInteger(number) && number > 0 ? number : 1
33
+ ),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Build the variables from isolation options (`runIsolated`, `docker run`).
39
+ * @param {object} options - {sessionId|executionId, containerName|session, attemptNumber}
40
+ * @returns {object} Variables, or {}
41
+ */
42
+ function executionEnvFromOptions(options = {}) {
43
+ return buildExecutionEnv({
44
+ executionId: options.sessionId || options.executionId,
45
+ sessionName: options.containerName || options.session,
46
+ attempt: options.attemptNumber,
47
+ });
48
+ }
49
+
50
+ /**
51
+ * `KEY=value` assignments, e.g. for `docker run -e`.
52
+ * @param {object} env - Variables
53
+ * @returns {string[]} Assignments
54
+ */
55
+ function executionEnvAssignments(env = {}) {
56
+ return Object.entries(env).map(([key, value]) => `${key}=${value}`);
57
+ }
58
+
59
+ /**
60
+ * POSIX `export` statement for the variables, without a trailing separator.
61
+ * @param {object} env - Variables
62
+ * @returns {string} Statement, or '' when there is nothing to export
63
+ */
64
+ function buildExecutionEnvExport(env = {}) {
65
+ const entries = Object.entries(env);
66
+ if (!entries.length) {
67
+ return '';
68
+ }
69
+ return `export ${entries
70
+ .map(([key, value]) => `${key}=${shellQuote(value)}`)
71
+ .join(' ')}`;
72
+ }
73
+
74
+ /**
75
+ * Prefix a shell command with the export statement. Used by the backends
76
+ * whose command runs through a shell that does not inherit our environment:
77
+ * a running tmux server, ssh, and `sudo` (which resets the environment).
78
+ * @param {string} command - Shell command
79
+ * @param {object} env - Variables
80
+ * @returns {string} Prefixed command
81
+ */
82
+ function prefixWithExecutionEnv(command, env = {}) {
83
+ const statement = buildExecutionEnvExport(env);
84
+ return statement ? `${statement}; ${command}` : command;
85
+ }
86
+
87
+ /**
88
+ * Child-process environment with the variables applied.
89
+ * @param {object} env - Variables
90
+ * @param {object} [base] - Base environment (defaults to process.env)
91
+ * @returns {object} Environment
92
+ */
93
+ function withExecutionEnv(env = {}, base = process.env) {
94
+ return { ...base, ...env };
95
+ }
96
+
97
+ /**
98
+ * True when this process was itself started by execution `executionId`, i.e.
99
+ * it is the next level of a stacked isolation that passes `--session-id`
100
+ * down (see command-builder.js).
101
+ * @param {string} executionId - Execution UUID
102
+ * @param {object} [env] - Environment to inspect
103
+ * @returns {boolean}
104
+ */
105
+ function isNestedLaunchOf(executionId, env = process.env) {
106
+ return Boolean(executionId) && env[EXECUTION_ID_ENV] === executionId;
107
+ }
108
+
109
+ module.exports = {
110
+ EXECUTION_ID_ENV,
111
+ SESSION_NAME_ENV,
112
+ ATTEMPT_ENV,
113
+ buildExecutionEnv,
114
+ executionEnvFromOptions,
115
+ executionEnvAssignments,
116
+ buildExecutionEnvExport,
117
+ prefixWithExecutionEnv,
118
+ withExecutionEnv,
119
+ isNestedLaunchOf,
120
+ };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Names an execution answers to (issue #231).
3
+ *
4
+ * An execution is found by its UUID, its backend session name, the session
5
+ * names it used before a resume (`options.sessionNameHistory`, #162), its
6
+ * display name (`options.name`, set by `--rename`) and its aliases
7
+ * (`options.aliases`, added by `--alias` and `--rename`). Aliases are kept
8
+ * apart from `sessionNameHistory` because that list names real backend
9
+ * sessions: `--cleanup` removes those containers and `--resume` derives the
10
+ * root session from it.
11
+ */
12
+
13
+ function hasName(record, identifier) {
14
+ const options = record.options || {};
15
+ return (
16
+ options.name === identifier ||
17
+ (Array.isArray(options.aliases) && options.aliases.includes(identifier))
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Resolve an identifier among stored records: UUID, then current session
23
+ * name, then earlier session names, then display name and aliases.
24
+ * @param {object[]} records - Stored records
25
+ * @param {string} identifier - Any name of the execution
26
+ * @returns {object|null} The record, or null
27
+ */
28
+ function findRecord(records, identifier) {
29
+ if (identifier === null || identifier === undefined || identifier === '') {
30
+ return null;
31
+ }
32
+ return (
33
+ records.find((r) => r.uuid === identifier) ||
34
+ records.find((r) => r.options && r.options.sessionName === identifier) ||
35
+ records.find(
36
+ (r) =>
37
+ r.options &&
38
+ Array.isArray(r.options.sessionNameHistory) &&
39
+ r.options.sessionNameHistory.includes(identifier)
40
+ ) ||
41
+ records.find((r) => hasName(r, identifier)) ||
42
+ null
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Every name the record answers to, UUID first, without duplicates.
48
+ * @param {object} record - Execution record
49
+ * @returns {string[]} Names
50
+ */
51
+ function executionAliases(record) {
52
+ const options = record.options || {};
53
+ const names = [
54
+ record.uuid,
55
+ ...(options.sessionNameHistory || []),
56
+ options.sessionName,
57
+ ...(options.aliases || []),
58
+ options.name,
59
+ ].filter((name) => typeof name === 'string' && name);
60
+ return [...new Set(names)];
61
+ }
62
+
63
+ /** True once the record has a caller-given name (`--alias`/`--rename`). */
64
+ function hasUserNames(record) {
65
+ const options = record.options || {};
66
+ return Boolean(options.name) || (options.aliases || []).length > 0;
67
+ }
68
+
69
+ /**
70
+ * Run `mutate(records)` under the store's database lock and persist the
71
+ * records it returns as changed.
72
+ * @param {object} store - ExecutionStore
73
+ * @param {Function} mutate - (records) => changed record, or null for none
74
+ * @returns {*} What `mutate` returned
75
+ */
76
+ function withStoreLock(store, mutate) {
77
+ const { LockManager } = require('./execution-store');
78
+ const lock = new LockManager(store.lockFilePath);
79
+ if (!lock.acquire()) {
80
+ throw new Error('Failed to acquire lock for database write');
81
+ }
82
+ try {
83
+ const records = store.readLinoRecords();
84
+ const changed = mutate(records);
85
+ if (changed) {
86
+ store.writeLinoRecords(records);
87
+ if (store.useLinks) {
88
+ store.writeLinksRecord(changed);
89
+ }
90
+ }
91
+ return changed;
92
+ } finally {
93
+ lock.release();
94
+ }
95
+ }
96
+
97
+ function validateName(name) {
98
+ if (typeof name !== 'string' || !name.trim()) {
99
+ throw new Error('The new name must not be empty');
100
+ }
101
+ if (name.startsWith('-')) {
102
+ throw new Error(`Invalid name "${name}": names cannot start with "-"`);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Add a name to an execution (`$ --alias <id> <name>`) or rename it
108
+ * (`$ --rename <id> <name>`, which keeps the previous display name as an
109
+ * alias). The backend session/container keeps its name; only lookup and the
110
+ * displayed name change.
111
+ * @param {object} store - ExecutionStore
112
+ * @param {string} identifier - Any current name of the execution
113
+ * @param {string} name - Name to add
114
+ * @param {object} [options] - {rename: boolean}
115
+ * @returns {{success: boolean, output?: string, error?: string}}
116
+ */
117
+ function nameExecution(store, identifier, name, options = {}) {
118
+ if (!store) {
119
+ return { success: false, error: 'Execution tracking is disabled.' };
120
+ }
121
+ try {
122
+ validateName(name);
123
+ let unchanged = false;
124
+ const record = withStoreLock(store, (records) => {
125
+ const target = findRecord(records, identifier);
126
+ if (!target) {
127
+ throw new Error(
128
+ `No execution found with UUID or session name: ${identifier}`
129
+ );
130
+ }
131
+ const owner = findRecord(records, name);
132
+ if (owner && owner.uuid !== target.uuid) {
133
+ throw new Error(
134
+ `Name "${name}" already refers to execution ${owner.uuid}`
135
+ );
136
+ }
137
+ const opts = (target.options = target.options || {});
138
+ const aliases = [...(opts.aliases || [])];
139
+ if (options.rename) {
140
+ const previous = opts.name || opts.sessionName || target.uuid;
141
+ if (previous === name) {
142
+ unchanged = true;
143
+ return null;
144
+ }
145
+ // A session name or UUID still resolves on its own; an earlier
146
+ // display name has to become an alias to keep resolving.
147
+ if (opts.name) {
148
+ aliases.push(previous);
149
+ }
150
+ opts.name = name;
151
+ } else if (owner) {
152
+ unchanged = true;
153
+ return null;
154
+ } else {
155
+ aliases.push(name);
156
+ }
157
+ opts.aliases = [...new Set(aliases)].filter((a) => a !== opts.name);
158
+ if (opts.aliases.length === 0) {
159
+ delete opts.aliases;
160
+ }
161
+ return target;
162
+ });
163
+ const current = record || store.get(identifier);
164
+ const action = options.rename ? 'Renamed' : 'Aliased';
165
+ return {
166
+ success: true,
167
+ output: `${unchanged ? 'Unchanged' : action} execution ${current.uuid}\naliases: [${executionAliases(current).join(', ')}]`,
168
+ };
169
+ } catch (error) {
170
+ return { success: false, error: error.message };
171
+ }
172
+ }
173
+
174
+ module.exports = {
175
+ executionAliases,
176
+ findRecord,
177
+ hasUserNames,
178
+ nameExecution,
179
+ withStoreLock,
180
+ };
@@ -336,6 +336,7 @@ function recoverKilledExecution(params = {}) {
336
336
  record.options.exitEvidence = {
337
337
  mainOom: terminalOom,
338
338
  daemonRestart: logged.daemonRestart,
339
+ ...evidence.sourceOf(logged),
339
340
  };
340
341
  let recoveryLimits = null;
341
342
  if (opts.onKillResumeMemory && terminalOom) {
@@ -379,6 +380,23 @@ function recoverKilledExecution(params = {}) {
379
380
  }
380
381
  }
381
382
 
383
+ if (opts.commandHandoff && record.uuid) {
384
+ // `docker start` keeps the creation env; refresh START_ATTEMPT (#230).
385
+ try {
386
+ require('./docker-command-handoff').writeExecutionEnvHandoff(
387
+ containerName,
388
+ require('./execution-env').buildExecutionEnv({
389
+ executionId: record.uuid,
390
+ sessionName: containerName,
391
+ attempt: require('./execution-attempt').nextAttemptNumber(record),
392
+ }),
393
+ runner
394
+ );
395
+ } catch {
396
+ // Non-fatal: the command still runs, with the previous START_ATTEMPT.
397
+ }
398
+ }
399
+
382
400
  const since = now().toISOString();
383
401
  const {
384
402
  appendLifecycle,
@@ -473,7 +491,9 @@ function recoverKilledExecution(params = {}) {
473
491
  const updated = runner(getDockerCommand(), [
474
492
  'update',
475
493
  ...resourceLimits.filter((s) =>
476
- /^--(?:memory|memory-swap|cpus|cpu-quota|cpu-period)=/.test(s)
494
+ /^--(?:memory|memory-swap|memory-reservation|cpus|cpu-quota|cpu-period)=/.test(
495
+ s
496
+ )
477
497
  ),
478
498
  containerName,
479
499
  ]);
@@ -34,6 +34,7 @@ const {
34
34
  appendLifecycle,
35
35
  archiveAttempt,
36
36
  createAttempt,
37
+ nextAttemptNumber,
37
38
  patchAttempt,
38
39
  } = require('./execution-attempt');
39
40
  const { runCommand } = require('./execution-control');
@@ -46,6 +47,7 @@ const { SessionState, probeSession } = require('./session-probe');
46
47
  const { getDockerNetworks } = require('./docker-network-lifecycle');
47
48
  const {
48
49
  writeCommandHandoff,
50
+ writeExecutionEnvHandoff,
49
51
  buildCommandHandoffArgs,
50
52
  commandHandoffPath,
51
53
  } = require('./docker-command-handoff');
@@ -110,6 +112,8 @@ function buildLaunchOptions(record) {
110
112
  rootSession:
111
113
  opts.rootSession || opts.sessionNameHistory?.[0] || opts.sessionName,
112
114
  resumeCount: (Number(opts.resumeCount) || 0) + 1,
115
+ // START_ATTEMPT of the relaunched command (issue #230).
116
+ attemptNumber: nextAttemptNumber(record),
113
117
  image: opts.image || null,
114
118
  session: opts.sessionName,
115
119
  detached: true,
@@ -123,6 +127,7 @@ function buildLaunchOptions(record) {
123
127
  useCommandStream: opts.useCommandStream || false,
124
128
  shell: opts.shell || 'auto',
125
129
  privileged: opts.privileged || false,
130
+ init: opts.init || false,
126
131
  env: opts.env || [],
127
132
  volumes: opts.volumes || [],
128
133
  mounts: opts.mounts || [],
@@ -289,6 +294,7 @@ function buildResumePlan(
289
294
  session: newSessionName,
290
295
  containerName: newSessionName,
291
296
  sessionId: record.uuid,
297
+ attemptNumber: nextAttemptNumber(record),
292
298
  rootSession:
293
299
  opts.rootSession || opts.sessionNameHistory?.[0] || sessionName,
294
300
  resumeCount: attempt,
@@ -530,7 +536,9 @@ async function resumeExecution(store, identifier, deps = {}) {
530
536
  args: [
531
537
  'update',
532
538
  ...resources.resourceLimits.filter((s) =>
533
- /^--(?:memory|memory-swap|cpus|cpu-quota|cpu-period)=/.test(s)
539
+ /^--(?:memory|memory-swap|memory-reservation|cpus|cpu-quota|cpu-period)=/.test(
540
+ s
541
+ )
534
542
  ),
535
543
  plan.sessionName,
536
544
  ],
@@ -657,6 +665,18 @@ async function resumeExecution(store, identifier, deps = {}) {
657
665
  );
658
666
  }
659
667
  }
668
+ if (plan.mode === ResumeMode.DOCKER_START && opts.commandHandoff) {
669
+ // `docker start` keeps the creation env; refresh START_ATTEMPT (#230).
670
+ writeExecutionEnvHandoff(
671
+ plan.sessionName,
672
+ require('./execution-env').buildExecutionEnv({
673
+ executionId: record.uuid,
674
+ sessionName: plan.sessionName,
675
+ attempt: attempt.number,
676
+ }),
677
+ runner
678
+ );
679
+ }
660
680
  for (const step of plan.steps) {
661
681
  let result;
662
682
  try {