start-command 0.35.2 → 0.35.4
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.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/lib/cgroup-memory.js +18 -13
- package/src/lib/detached-finalize.js +31 -7
- package/src/lib/detached-output.js +61 -0
- package/src/lib/docker-cleanup.js +20 -2
- package/src/lib/execution-attempt.js +153 -0
- package/src/lib/execution-recovery.js +61 -1
- package/src/lib/execution-resume-all.js +12 -1
- package/src/lib/execution-resume.js +70 -20
- package/src/lib/execution-store.js +31 -0
- package/src/lib/isolation-log-utils.js +3 -3
- package/src/lib/isolation.js +9 -7
- package/src/lib/status-formatter.js +38 -6
- package/test/execution-resume.js +2 -1
- package/test/regression-176.js +5 -1
- package/test/regression-182.js +18 -18
- package/test/regression-185.js +111 -0
- package/test/regression-187.js +546 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.35.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6544236: Scope explicit resume status, memory evidence and log reads to execution attempts; preserve earlier evidence in attempt history and report launch, watcher, output and terminal lifecycle separately.
|
|
8
|
+
|
|
9
|
+
## 0.35.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- ec0a9cf: Report OOM kill scope as unknown when only cgroup memory counters are available. Allocation events and killed processes have different units, so their comparison cannot establish container, parent or host scope. Preserve raw counters and the exit-code guard for earlier child OOM kills.
|
|
14
|
+
|
|
3
15
|
## 0.35.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/lib/cgroup-memory.js
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* it says that *some* process in the container was OOM-killed at some point,
|
|
6
6
|
* but not how many, not whether the container hit its own `--memory` limit or
|
|
7
7
|
* the whole host ran out, and not how close the execution came to its limit.
|
|
8
|
-
* The kernel
|
|
8
|
+
* The kernel provides raw counters per cgroup
|
|
9
9
|
* (https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files):
|
|
10
|
-
* memory.events `oom`
|
|
11
|
-
* memory.events `oom_kill` processes killed here by *any* OOM killer
|
|
12
|
-
* `oom_kill > oom` points to a host-wide (or
|
|
13
|
-
* parent cgroup) OOM
|
|
10
|
+
* memory.events `oom` allocation events reaching the memory limit
|
|
11
|
+
* memory.events `oom_kill` processes killed here by *any* OOM killer
|
|
14
12
|
* memory.peak / memory.max peak usage (Linux 5.19+) and limit (`max` when
|
|
15
13
|
* unlimited)
|
|
14
|
+
* These counters have different units and are hierarchical. A group OOM can
|
|
15
|
+
* kill several processes for one allocation event; earlier allocation events
|
|
16
|
+
* can also coexist with a later host OOM. Their comparison cannot establish
|
|
17
|
+
* container, parent or host scope (issue #185).
|
|
16
18
|
*
|
|
17
19
|
* The cgroup is removed when the container stops, so the values cannot be read
|
|
18
20
|
* afterwards. The detached completion watcher therefore samples them while the
|
|
@@ -122,9 +124,8 @@ function buildCgroupMemoryLogSnippet(quotedLogPath) {
|
|
|
122
124
|
// A function, so `$1`.. of the watcher script itself stay untouched.
|
|
123
125
|
return (
|
|
124
126
|
`__start_command_cgroup_log() { __scm_note=''; ` +
|
|
125
|
-
`if [ "$4" -gt 0 ] 2>/dev/null; then
|
|
126
|
-
`__scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.
|
|
127
|
-
`else __scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.CONTAINER_LIMIT]})'; fi; fi; ` +
|
|
127
|
+
`if [ "$4" -gt 0 ] 2>/dev/null; then ` +
|
|
128
|
+
`__scm_note=' (${OOM_SCOPE_NOTES[OOM_SCOPE.UNKNOWN]})'; fi; ` +
|
|
128
129
|
`printf 'Memory: memory.max=%s memory.peak=%s oom=%s oom_kill=%s%s\\n' ` +
|
|
129
130
|
`"$1" "$2" "$3" "$4" "$__scm_note"; }; ` +
|
|
130
131
|
`if [ -n "$${v.sample}" ]; then __start_command_cgroup_log $${v.sample} >> ${quotedLogPath}; fi`
|
|
@@ -151,14 +152,17 @@ function formatCgroupMemoryLogLine(text) {
|
|
|
151
152
|
|
|
152
153
|
/** Where the OOM killer that killed processes in the container came from. */
|
|
153
154
|
const OOM_SCOPE = {
|
|
155
|
+
UNKNOWN: 'unknown',
|
|
156
|
+
// Retained scope names; raw counters alone cannot establish either one.
|
|
154
157
|
CONTAINER_LIMIT: 'container-limit',
|
|
155
158
|
HOST_OR_PARENT: 'host-or-parent',
|
|
156
159
|
};
|
|
157
160
|
|
|
158
161
|
const OOM_SCOPE_NOTES = {
|
|
162
|
+
[OOM_SCOPE.UNKNOWN]: 'OOM kill scope unknown',
|
|
159
163
|
[OOM_SCOPE.CONTAINER_LIMIT]: 'the container hit its own memory limit',
|
|
160
164
|
[OOM_SCOPE.HOST_OR_PARENT]:
|
|
161
|
-
'
|
|
165
|
+
'a host-wide or parent cgroup OOM killed processes here',
|
|
162
166
|
};
|
|
163
167
|
|
|
164
168
|
function parseCounter(text) {
|
|
@@ -216,16 +220,17 @@ function normalizeCgroupMemory(value) {
|
|
|
216
220
|
|
|
217
221
|
/**
|
|
218
222
|
* @param {?object} memory - Counters
|
|
219
|
-
*
|
|
223
|
+
* Raw counters do not attribute the OOM killer's scope. Separately attributed
|
|
224
|
+
* kernel/cgroup evidence would be needed to establish container, parent or
|
|
225
|
+
* host scope. An unknown oomEvents count remains unknown, never zero.
|
|
226
|
+
* @returns {?string} OOM_SCOPE.UNKNOWN for observed kills, otherwise null
|
|
220
227
|
*/
|
|
221
228
|
function describeCgroupOomScope(memory) {
|
|
222
229
|
const counters = normalizeCgroupMemory(memory);
|
|
223
230
|
if (!counters || !(counters.oomKills > 0)) {
|
|
224
231
|
return null;
|
|
225
232
|
}
|
|
226
|
-
return
|
|
227
|
-
? OOM_SCOPE.HOST_OR_PARENT
|
|
228
|
-
: OOM_SCOPE.CONTAINER_LIMIT;
|
|
233
|
+
return OOM_SCOPE.UNKNOWN;
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
function formatBytes(bytes) {
|
|
@@ -85,6 +85,17 @@ function finalizeDetachedExecution(options = {}) {
|
|
|
85
85
|
if (!record) {
|
|
86
86
|
return { updated: false, reason: 'record-not-found' };
|
|
87
87
|
}
|
|
88
|
+
const started = normalizeDockerTimestamp(options.startedAt);
|
|
89
|
+
const attemptNumber = options.attemptNumber ?? null;
|
|
90
|
+
if (
|
|
91
|
+
record.attempt &&
|
|
92
|
+
((attemptNumber !== null &&
|
|
93
|
+
Number(attemptNumber) !== record.attempt.number) ||
|
|
94
|
+
(started && Date.parse(started) < Date.parse(record.attempt.startedAt)) ||
|
|
95
|
+
(attemptNumber === null && !started))
|
|
96
|
+
) {
|
|
97
|
+
return { updated: false, reason: 'stale-attempt' };
|
|
98
|
+
}
|
|
88
99
|
if (record.status === 'executed' && record.endTime) {
|
|
89
100
|
// Already finalized (e.g. a `--status` query got there first, or the
|
|
90
101
|
// watcher ran twice after a resume). Nothing to correct.
|
|
@@ -146,11 +157,24 @@ function finalizeDetachedExecution(options = {}) {
|
|
|
146
157
|
record.options = { ...(record.options || {}), containerError };
|
|
147
158
|
}
|
|
148
159
|
|
|
160
|
+
const {
|
|
161
|
+
appendLifecycle,
|
|
162
|
+
readAttemptActivity,
|
|
163
|
+
} = require('./execution-attempt');
|
|
164
|
+
readAttemptActivity(record);
|
|
149
165
|
try {
|
|
150
166
|
store.save(record);
|
|
151
167
|
} catch (err) {
|
|
152
168
|
return { updated: false, reason: `save-failed: ${err.message}`, record };
|
|
153
169
|
}
|
|
170
|
+
appendLifecycle(record, 'terminal', {
|
|
171
|
+
status: record.status,
|
|
172
|
+
exitCode: record.exitCode,
|
|
173
|
+
endTime: record.endTime,
|
|
174
|
+
exitReason: record.exitReason || null,
|
|
175
|
+
oomKilled: record.oomKilled ?? null,
|
|
176
|
+
cgroupMemory: record.cgroupMemory || null,
|
|
177
|
+
});
|
|
154
178
|
return { updated: true, record };
|
|
155
179
|
}
|
|
156
180
|
|
|
@@ -163,12 +187,10 @@ function finalizeDetachedExecution(options = {}) {
|
|
|
163
187
|
function resolveReason(record) {
|
|
164
188
|
try {
|
|
165
189
|
const { resolveExitReason } = require('./exit-reason');
|
|
166
|
-
const {
|
|
167
|
-
|
|
168
|
-
readLogTail,
|
|
169
|
-
} = require('./isolation-log-utils');
|
|
190
|
+
const { FATAL_MARKER_TAIL_BYTES } = require('./isolation-log-utils');
|
|
191
|
+
const { readAttemptLogTail } = require('./execution-attempt');
|
|
170
192
|
const logTail = record.logPath
|
|
171
|
-
?
|
|
193
|
+
? readAttemptLogTail(record, FATAL_MARKER_TAIL_BYTES)
|
|
172
194
|
: null;
|
|
173
195
|
return resolveExitReason({
|
|
174
196
|
exitCode: record.exitCode,
|
|
@@ -251,13 +273,13 @@ function reconcileFinalizedRecord(store, record) {
|
|
|
251
273
|
* @param {string} executionId - UUID of the record to finalize
|
|
252
274
|
* @returns {string} POSIX shell fragment
|
|
253
275
|
*/
|
|
254
|
-
function buildDetachedFinalizeSnippet(executionId) {
|
|
276
|
+
function buildDetachedFinalizeSnippet(executionId, attemptNumber = null) {
|
|
255
277
|
const v = SHELL_VARS;
|
|
256
278
|
return (
|
|
257
279
|
`${shellQuote(process.execPath)} ${shellQuote(__filename)} ` +
|
|
258
280
|
`${shellQuote(executionId)} "$${v.exit}" "$${v.oom}" ` +
|
|
259
281
|
`"$${v.started}" "$${v.finished}" "$${v.error}" "$${v.running}" ` +
|
|
260
|
-
`"$${CGROUP_SHELL_VARS.sample}" >/dev/null 2>&1 || true`
|
|
282
|
+
`"$${CGROUP_SHELL_VARS.sample}"${attemptNumber === null ? '' : ` ${shellQuote(attemptNumber)}`} >/dev/null 2>&1 || true`
|
|
261
283
|
);
|
|
262
284
|
}
|
|
263
285
|
|
|
@@ -279,6 +301,7 @@ function main(argv) {
|
|
|
279
301
|
containerError,
|
|
280
302
|
running,
|
|
281
303
|
cgroupMemory,
|
|
304
|
+
attemptNumber,
|
|
282
305
|
] = argv;
|
|
283
306
|
if (!executionId || process.env.START_DISABLE_TRACKING === 'true') {
|
|
284
307
|
return;
|
|
@@ -300,6 +323,7 @@ function main(argv) {
|
|
|
300
323
|
containerError,
|
|
301
324
|
running,
|
|
302
325
|
cgroupMemory,
|
|
326
|
+
attemptNumber: attemptNumber || null,
|
|
303
327
|
});
|
|
304
328
|
} catch {
|
|
305
329
|
// Deliberately silent: see the exit-0 contract above.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Stream Docker output without buffering lines; observe only timestamped output. */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const { StringDecoder } = require('string_decoder');
|
|
4
|
+
const { activityPath } = require('./execution-attempt');
|
|
5
|
+
|
|
6
|
+
class OutputObserver {
|
|
7
|
+
constructor(logPath, number, since) {
|
|
8
|
+
this.logPath = logPath;
|
|
9
|
+
this.activity = activityPath({ logPath, attempt: { number } });
|
|
10
|
+
this.since = Date.parse(since);
|
|
11
|
+
this.prefix = '';
|
|
12
|
+
this.atLineStart = true;
|
|
13
|
+
this.latest = null;
|
|
14
|
+
this.decoder = new StringDecoder('utf8');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
write(chunk) {
|
|
18
|
+
fs.appendFileSync(this.logPath, chunk);
|
|
19
|
+
let latest = null;
|
|
20
|
+
for (const character of this.decoder.write(chunk)) {
|
|
21
|
+
if (character === '\n') {
|
|
22
|
+
this.atLineStart = true;
|
|
23
|
+
this.prefix = '';
|
|
24
|
+
} else if (this.atLineStart) {
|
|
25
|
+
this.prefix += character;
|
|
26
|
+
if (character === ' ') {
|
|
27
|
+
const text = this.prefix.trim();
|
|
28
|
+
const time = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(
|
|
29
|
+
text
|
|
30
|
+
)
|
|
31
|
+
? Date.parse(text)
|
|
32
|
+
: NaN;
|
|
33
|
+
if (
|
|
34
|
+
Number.isFinite(time) &&
|
|
35
|
+
time >= this.since &&
|
|
36
|
+
(this.latest === null || time > this.latest)
|
|
37
|
+
) {
|
|
38
|
+
this.latest = time;
|
|
39
|
+
latest = new Date(time).toISOString();
|
|
40
|
+
}
|
|
41
|
+
this.atLineStart = false;
|
|
42
|
+
} else if (this.prefix.length > 80) {
|
|
43
|
+
this.atLineStart = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (latest) {
|
|
48
|
+
const temporary = `${this.activity}.${process.pid}.tmp`;
|
|
49
|
+
fs.writeFileSync(temporary, latest);
|
|
50
|
+
fs.renameSync(temporary, this.activity);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (require.main === module) {
|
|
56
|
+
const [logPath, number, since] = process.argv.slice(2);
|
|
57
|
+
const observer = new OutputObserver(logPath, Number(number), since);
|
|
58
|
+
process.stdin.on('data', (chunk) => observer.write(chunk));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { OutputObserver };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { spawn, spawnSync } = require('child_process');
|
|
2
|
+
const path = require('path');
|
|
2
3
|
const {
|
|
3
4
|
appendLogFile,
|
|
4
5
|
createShellLogFooterSnippet,
|
|
@@ -381,7 +382,16 @@ function buildDetachedDockerCompletionScript(
|
|
|
381
382
|
const since = watcherOptions.since
|
|
382
383
|
? ` --since ${shellQuote(watcherOptions.since)}`
|
|
383
384
|
: '';
|
|
384
|
-
|
|
385
|
+
if (watcherOptions.attemptNumber) {
|
|
386
|
+
const capture = shellQuote(path.join(__dirname, 'detached-output.js'));
|
|
387
|
+
parts.push(
|
|
388
|
+
`docker logs -f --timestamps${since} ${quotedName} 2>&1 | ${shellQuote(process.execPath)} ${capture} ${quotedLogPath} ${watcherOptions.attemptNumber} ${shellQuote(watcherOptions.since)}`
|
|
389
|
+
);
|
|
390
|
+
} else {
|
|
391
|
+
parts.push(
|
|
392
|
+
`docker logs -f${since} ${quotedName} >> ${quotedLogPath} 2>&1`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
385
395
|
parts.push(buildDockerWaitForExitSnippet(containerName, quotedLogPath));
|
|
386
396
|
parts.push(buildCgroupSamplerStopSnippet());
|
|
387
397
|
parts.push(buildDockerStateSnippet(containerName));
|
|
@@ -420,7 +430,9 @@ function buildDetachedDockerCompletionScript(
|
|
|
420
430
|
|
|
421
431
|
if (executionId) {
|
|
422
432
|
// Last, so the record is only marked terminal once the log is complete.
|
|
423
|
-
exited.push(
|
|
433
|
+
exited.push(
|
|
434
|
+
buildDetachedFinalizeSnippet(executionId, watcherOptions.attemptNumber)
|
|
435
|
+
);
|
|
424
436
|
}
|
|
425
437
|
|
|
426
438
|
const stillRunning = logPath
|
|
@@ -474,7 +486,13 @@ function startDetachedDockerCompletionWatcher(
|
|
|
474
486
|
stdio: 'ignore',
|
|
475
487
|
}
|
|
476
488
|
);
|
|
489
|
+
watcher.on('error', (error) => {
|
|
490
|
+
if (process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true') {
|
|
491
|
+
console.error(`[DEBUG] completion watcher failed: ${error.message}`);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
477
494
|
watcher.unref();
|
|
495
|
+
return Boolean(watcher.pid);
|
|
478
496
|
}
|
|
479
497
|
|
|
480
498
|
function spawnAttachedDocker(dockerArgs, logPath) {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Attempt-scoped evidence for logical executions sharing a UUID and log. */
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const { appendLogFile, readLogTail } = require('./isolation-log-utils');
|
|
4
|
+
|
|
5
|
+
const TERMINAL_FIELDS = [
|
|
6
|
+
'endTimeSource',
|
|
7
|
+
'observedAt',
|
|
8
|
+
'staleDetectedAt',
|
|
9
|
+
'containerStartedAt',
|
|
10
|
+
'exitReason',
|
|
11
|
+
'oomKilled',
|
|
12
|
+
'memoryExhausted',
|
|
13
|
+
'memoryExhaustedReason',
|
|
14
|
+
'cgroupMemory',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function activityPath(record) {
|
|
18
|
+
return `${record.logPath}.attempt-${record.attempt.number}.activity`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readAttemptActivity(record) {
|
|
22
|
+
if (!record.attempt || !record.logPath) {
|
|
23
|
+
return record;
|
|
24
|
+
}
|
|
25
|
+
let fd;
|
|
26
|
+
try {
|
|
27
|
+
fd = fs.openSync(activityPath(record), 'r');
|
|
28
|
+
const buffer = Buffer.alloc(128);
|
|
29
|
+
const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
30
|
+
const at = buffer.subarray(0, length).toString().trim();
|
|
31
|
+
if (
|
|
32
|
+
Number.isFinite(Date.parse(at)) &&
|
|
33
|
+
Date.parse(at) >= Date.parse(record.attempt.startedAt)
|
|
34
|
+
) {
|
|
35
|
+
record.attempt = { ...record.attempt, lastOutputAt: at };
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// Quiet, missing, or unreadable output is not evidence of task progress.
|
|
39
|
+
} finally {
|
|
40
|
+
if (fd !== undefined) {
|
|
41
|
+
fs.closeSync(fd);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return record;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createAttempt(record, plan) {
|
|
48
|
+
let logOffset = null;
|
|
49
|
+
if (record.logPath) {
|
|
50
|
+
try {
|
|
51
|
+
logOffset = fs.statSync(record.logPath).size;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code === 'ENOENT') {
|
|
54
|
+
logOffset = 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
number:
|
|
60
|
+
(record.attempt?.number ||
|
|
61
|
+
(Number(record.options?.resumeCount) || 0) +
|
|
62
|
+
(Number(record.options?.recoveryAttempts) || 0) +
|
|
63
|
+
1) + 1,
|
|
64
|
+
startedAt: new Date().toISOString(),
|
|
65
|
+
logOffset,
|
|
66
|
+
lastOutputAt: null,
|
|
67
|
+
launchAcceptedAt: null,
|
|
68
|
+
watcherAttachedAt: null,
|
|
69
|
+
mode: plan.mode,
|
|
70
|
+
previousSessionName: record.options?.sessionName || null,
|
|
71
|
+
sessionName: plan.newSessionName || plan.sessionName,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function archiveAttempt(record) {
|
|
76
|
+
readAttemptActivity(record);
|
|
77
|
+
const snapshot = {
|
|
78
|
+
...(record.attempt || {
|
|
79
|
+
number:
|
|
80
|
+
(Number(record.options?.resumeCount) || 0) +
|
|
81
|
+
(Number(record.options?.recoveryAttempts) || 0) +
|
|
82
|
+
1,
|
|
83
|
+
startedAt: record.options?.resumedAt || record.startTime,
|
|
84
|
+
logOffset: null,
|
|
85
|
+
}),
|
|
86
|
+
command: record.command,
|
|
87
|
+
sessionName: record.options?.sessionName || null,
|
|
88
|
+
containerId: record.options?.containerId || null,
|
|
89
|
+
containerError: record.options?.containerError || null,
|
|
90
|
+
status: record.status,
|
|
91
|
+
exitCode: record.exitCode,
|
|
92
|
+
endTime: record.endTime,
|
|
93
|
+
};
|
|
94
|
+
for (const field of TERMINAL_FIELDS) {
|
|
95
|
+
if (record[field] !== undefined) {
|
|
96
|
+
snapshot[field] = record[field];
|
|
97
|
+
}
|
|
98
|
+
record[field] = undefined;
|
|
99
|
+
}
|
|
100
|
+
record.attemptHistory = [...(record.attemptHistory || []), snapshot];
|
|
101
|
+
delete record.options.containerError;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function appendLifecycle(record, event, details = {}) {
|
|
105
|
+
if (!record.logPath || !record.attempt) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
appendLogFile(
|
|
109
|
+
record.logPath,
|
|
110
|
+
`\n[Start Command Lifecycle] ${JSON.stringify({
|
|
111
|
+
event,
|
|
112
|
+
at: new Date().toISOString(),
|
|
113
|
+
uuid: record.uuid,
|
|
114
|
+
attemptNumber: record.attempt.number,
|
|
115
|
+
...record.attempt,
|
|
116
|
+
...details,
|
|
117
|
+
})}\n`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function patchAttempt(store, record, fields) {
|
|
122
|
+
if (typeof store.patchAttempt === 'function') {
|
|
123
|
+
return store.patchAttempt(record.uuid, record.attempt.number, fields);
|
|
124
|
+
}
|
|
125
|
+
// Compatibility for consumers injecting a minimal in-memory store.
|
|
126
|
+
const current = store.get(record.uuid) || record;
|
|
127
|
+
if (current.attempt?.number !== record.attempt.number) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
current.attempt = { ...current.attempt, ...fields };
|
|
131
|
+
store.save(current);
|
|
132
|
+
return current;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readAttemptLogTail(record, bytes) {
|
|
136
|
+
if (
|
|
137
|
+
!record.logPath ||
|
|
138
|
+
(record.attempt && record.attempt.logOffset === null)
|
|
139
|
+
) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return readLogTail(record.logPath, bytes, record.attempt?.logOffset || 0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = {
|
|
146
|
+
activityPath,
|
|
147
|
+
appendLifecycle,
|
|
148
|
+
archiveAttempt,
|
|
149
|
+
createAttempt,
|
|
150
|
+
patchAttempt,
|
|
151
|
+
readAttemptActivity,
|
|
152
|
+
readAttemptLogTail,
|
|
153
|
+
};
|
|
@@ -318,8 +318,34 @@ function recoverKilledExecution(params = {}) {
|
|
|
318
318
|
}
|
|
319
319
|
|
|
320
320
|
const since = now().toISOString();
|
|
321
|
+
const {
|
|
322
|
+
appendLifecycle,
|
|
323
|
+
archiveAttempt,
|
|
324
|
+
createAttempt,
|
|
325
|
+
patchAttempt,
|
|
326
|
+
} = require('./execution-attempt');
|
|
327
|
+
const nextAttempt = record.attempt
|
|
328
|
+
? createAttempt(record, {
|
|
329
|
+
mode: 'automatic-recovery',
|
|
330
|
+
sessionName: containerName,
|
|
331
|
+
})
|
|
332
|
+
: null;
|
|
333
|
+
if (nextAttempt) {
|
|
334
|
+
nextAttempt.startedAt = since;
|
|
335
|
+
appendLifecycle(
|
|
336
|
+
{ uuid: record.uuid, logPath: record.logPath, attempt: nextAttempt },
|
|
337
|
+
'resume-started'
|
|
338
|
+
);
|
|
339
|
+
}
|
|
321
340
|
const started = runner(getDockerCommand(), ['start', containerName]);
|
|
322
341
|
if (!started.success) {
|
|
342
|
+
if (nextAttempt) {
|
|
343
|
+
appendLifecycle(
|
|
344
|
+
{ uuid: record.uuid, logPath: record.logPath, attempt: nextAttempt },
|
|
345
|
+
'launch-failed',
|
|
346
|
+
{ error: failureDetail(started) }
|
|
347
|
+
);
|
|
348
|
+
}
|
|
323
349
|
return giveUp(`docker start failed: ${failureDetail(started)}`);
|
|
324
350
|
}
|
|
325
351
|
|
|
@@ -327,6 +353,19 @@ function recoverKilledExecution(params = {}) {
|
|
|
327
353
|
const delayed = delayRange ? `, delayMs=${delayMs}` : '';
|
|
328
354
|
// The killed run's own counters: the resumed run starts a fresh cgroup.
|
|
329
355
|
const memory = parseCgroupMemorySample(params.cgroupMemory);
|
|
356
|
+
if (nextAttempt) {
|
|
357
|
+
Object.assign(record, {
|
|
358
|
+
status: 'executed',
|
|
359
|
+
exitCode: described.code,
|
|
360
|
+
endTime: params.finishedAt || null,
|
|
361
|
+
containerStartedAt: params.startedAt || undefined,
|
|
362
|
+
oomKilled: params.oomKilled === true || params.oomKilled === 'true',
|
|
363
|
+
cgroupMemory: memory || undefined,
|
|
364
|
+
});
|
|
365
|
+
archiveAttempt(record);
|
|
366
|
+
nextAttempt.launchAcceptedAt = new Date().toISOString();
|
|
367
|
+
record.attempt = nextAttempt;
|
|
368
|
+
}
|
|
330
369
|
const counted = memory
|
|
331
370
|
? ['oomEvents', 'oomKills']
|
|
332
371
|
.filter((field) => memory[field] !== null)
|
|
@@ -350,6 +389,12 @@ function recoverKilledExecution(params = {}) {
|
|
|
350
389
|
record.exitReason = undefined;
|
|
351
390
|
record.oomKilled = undefined;
|
|
352
391
|
record.cgroupMemory = undefined;
|
|
392
|
+
record.memoryExhausted = undefined;
|
|
393
|
+
record.memoryExhaustedReason = undefined;
|
|
394
|
+
record.endTimeSource = undefined;
|
|
395
|
+
record.observedAt = undefined;
|
|
396
|
+
record.staleDetectedAt = undefined;
|
|
397
|
+
record.containerStartedAt = undefined;
|
|
353
398
|
try {
|
|
354
399
|
store.save(record);
|
|
355
400
|
} catch {
|
|
@@ -359,13 +404,28 @@ function recoverKilledExecution(params = {}) {
|
|
|
359
404
|
|
|
360
405
|
const startWatcher =
|
|
361
406
|
params.startWatcher || startDetachedDockerCompletionWatcher;
|
|
407
|
+
if (nextAttempt) {
|
|
408
|
+
appendLifecycle(record, 'launch-accepted');
|
|
409
|
+
}
|
|
362
410
|
startWatcher(
|
|
363
411
|
containerName,
|
|
364
412
|
getDockerContainerCleanupPolicy(opts),
|
|
365
413
|
record.logPath || null,
|
|
366
414
|
record.uuid,
|
|
367
|
-
{
|
|
415
|
+
{
|
|
416
|
+
since,
|
|
417
|
+
recoverOnKill: true,
|
|
418
|
+
...(nextAttempt ? { attemptNumber: nextAttempt.number } : {}),
|
|
419
|
+
}
|
|
368
420
|
);
|
|
421
|
+
if (nextAttempt) {
|
|
422
|
+
const current = patchAttempt(store, record, {
|
|
423
|
+
watcherAttachedAt: new Date().toISOString(),
|
|
424
|
+
});
|
|
425
|
+
if (current) {
|
|
426
|
+
appendLifecycle(current, 'watcher-attached');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
369
429
|
return {
|
|
370
430
|
recovered: true,
|
|
371
431
|
reason: 'resumed',
|
|
@@ -131,7 +131,18 @@ function resumeAllExecutions(store, deps = {}) {
|
|
|
131
131
|
sessionName,
|
|
132
132
|
getDockerContainerCleanupPolicy(record.options || {}),
|
|
133
133
|
record.logPath || null,
|
|
134
|
-
record.uuid || null
|
|
134
|
+
record.uuid || null,
|
|
135
|
+
record.attempt
|
|
136
|
+
? {
|
|
137
|
+
since: record.attempt.startedAt,
|
|
138
|
+
attemptNumber: record.attempt.number,
|
|
139
|
+
recoverOnKill:
|
|
140
|
+
Boolean(record.options?.onKillResume) &&
|
|
141
|
+
['docker-start', 'automatic-recovery', 'relaunch'].includes(
|
|
142
|
+
record.attempt.mode
|
|
143
|
+
),
|
|
144
|
+
}
|
|
145
|
+
: {}
|
|
135
146
|
);
|
|
136
147
|
executions.push(
|
|
137
148
|
describeRecord(
|