start-command 0.35.3 → 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 +6 -0
- package/package.json +1 -1
- 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-187.js +546 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.35.3
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -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(
|
|
@@ -31,6 +31,12 @@ const {
|
|
|
31
31
|
readDockerResourceLimits,
|
|
32
32
|
} = require('./docker-resource-limits');
|
|
33
33
|
const { appendLogFile } = require('./isolation-log-utils');
|
|
34
|
+
const {
|
|
35
|
+
appendLifecycle,
|
|
36
|
+
archiveAttempt,
|
|
37
|
+
createAttempt,
|
|
38
|
+
patchAttempt,
|
|
39
|
+
} = require('./execution-attempt');
|
|
34
40
|
const { runCommand } = require('./execution-control');
|
|
35
41
|
const { SessionState, probeSession } = require('./session-probe');
|
|
36
42
|
const { getDockerNetworks } = require('./docker-network-lifecycle');
|
|
@@ -105,6 +111,7 @@ function buildLaunchOptions(record) {
|
|
|
105
111
|
resourceLimits: normalizeResourceLimits(opts.resourceLimits),
|
|
106
112
|
// Append to the same log so one logical session keeps one gap-free record.
|
|
107
113
|
logPath: record.logPath || null,
|
|
114
|
+
deferCompletionWatcher: true,
|
|
108
115
|
};
|
|
109
116
|
}
|
|
110
117
|
|
|
@@ -339,10 +346,17 @@ function formatResumeResult(result, outputFormat) {
|
|
|
339
346
|
* @param {?string} containerId - New container id, when one was created
|
|
340
347
|
* @returns {object} The updated record
|
|
341
348
|
*/
|
|
342
|
-
function applyResumeToRecord(
|
|
349
|
+
function applyResumeToRecord(
|
|
350
|
+
record,
|
|
351
|
+
plan,
|
|
352
|
+
containerId,
|
|
353
|
+
attempt = createAttempt(record, plan)
|
|
354
|
+
) {
|
|
355
|
+
archiveAttempt(record);
|
|
356
|
+
record.attempt = attempt;
|
|
343
357
|
const options = { ...(record.options || {}) };
|
|
344
358
|
options.resumeCount = plan.attempt;
|
|
345
|
-
options.resumedAt =
|
|
359
|
+
options.resumedAt = attempt.startedAt;
|
|
346
360
|
// A resume is a new deliberate start: launch-time recovery applies again.
|
|
347
361
|
delete options.stopRequestedAt;
|
|
348
362
|
if (plan.resourceLimits && plan.resourceLimits.length > 0) {
|
|
@@ -423,6 +437,13 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
423
437
|
}
|
|
424
438
|
|
|
425
439
|
let containerId = null;
|
|
440
|
+
const attempt = createAttempt(record, plan);
|
|
441
|
+
const lifecycleRecord = {
|
|
442
|
+
uuid: record.uuid,
|
|
443
|
+
logPath: record.logPath,
|
|
444
|
+
attempt,
|
|
445
|
+
};
|
|
446
|
+
appendLifecycle(lifecycleRecord, 'resume-started');
|
|
426
447
|
|
|
427
448
|
if (plan.mode === ResumeMode.RELAUNCH) {
|
|
428
449
|
const runIsolated = deps.runIsolated || require('./isolation').runIsolated;
|
|
@@ -432,6 +453,9 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
432
453
|
plan.launchOptions
|
|
433
454
|
);
|
|
434
455
|
if (!launchResult || !launchResult.success) {
|
|
456
|
+
appendLifecycle(lifecycleRecord, 'launch-failed', {
|
|
457
|
+
error: launchResult?.message || 'unknown error',
|
|
458
|
+
});
|
|
435
459
|
return {
|
|
436
460
|
success: false,
|
|
437
461
|
error: `Failed to relaunch ${plan.backend} session "${plan.sessionName}": ${
|
|
@@ -448,6 +472,7 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
448
472
|
(result.stderr || '').trim() ||
|
|
449
473
|
result.error ||
|
|
450
474
|
`exit code ${result.status}`;
|
|
475
|
+
appendLifecycle(lifecycleRecord, 'launch-failed', { error: detail });
|
|
451
476
|
return {
|
|
452
477
|
success: false,
|
|
453
478
|
error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${detail}`,
|
|
@@ -460,28 +485,53 @@ async function resumeExecution(store, identifier, deps = {}) {
|
|
|
460
485
|
if (limitsLine && record.logPath) {
|
|
461
486
|
appendLogFile(record.logPath, `${limitsLine}\n`);
|
|
462
487
|
}
|
|
463
|
-
|
|
464
|
-
// The completion watcher died with the previous run (or with the
|
|
465
|
-
// supervisor), so a new one must follow the resumed container.
|
|
466
|
-
const startWatcher =
|
|
467
|
-
deps.startWatcher || startDetachedDockerCompletionWatcher;
|
|
468
|
-
startWatcher(
|
|
469
|
-
activeSessionName(plan),
|
|
470
|
-
getDockerContainerCleanupPolicy(record.options || {}),
|
|
471
|
-
record.logPath || null,
|
|
472
|
-
record.uuid || null,
|
|
473
|
-
// `docker start` re-runs the launch-time selector, so the session keeps
|
|
474
|
-
// its kill recovery (issue #176); a snapshot container has no selector.
|
|
475
|
-
{
|
|
476
|
-
recoverOnKill:
|
|
477
|
-
plan.mode === ResumeMode.DOCKER_START && Boolean(opts.onKillResume),
|
|
478
|
-
}
|
|
479
|
-
);
|
|
480
488
|
}
|
|
481
489
|
|
|
482
490
|
const previousSessionName = plan.newSessionName ? plan.sessionName : null;
|
|
483
|
-
|
|
491
|
+
attempt.launchAcceptedAt = new Date().toISOString();
|
|
492
|
+
const updated = applyResumeToRecord(record, plan, containerId, attempt);
|
|
484
493
|
store.save(updated);
|
|
494
|
+
appendLifecycle(updated, 'launch-accepted');
|
|
495
|
+
|
|
496
|
+
if (plan.backend === 'docker') {
|
|
497
|
+
try {
|
|
498
|
+
const startWatcher =
|
|
499
|
+
deps.startWatcher || startDetachedDockerCompletionWatcher;
|
|
500
|
+
const watcher = startWatcher(
|
|
501
|
+
activeSessionName(plan),
|
|
502
|
+
getDockerContainerCleanupPolicy(updated.options),
|
|
503
|
+
updated.logPath || null,
|
|
504
|
+
updated.uuid || null,
|
|
505
|
+
{
|
|
506
|
+
since: attempt.startedAt,
|
|
507
|
+
attemptNumber: attempt.number,
|
|
508
|
+
recoverOnKill:
|
|
509
|
+
plan.mode !== ResumeMode.DOCKER_SNAPSHOT &&
|
|
510
|
+
Boolean(opts.onKillResume),
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
if (watcher === false) {
|
|
514
|
+
throw new Error('Watcher process could not be started');
|
|
515
|
+
}
|
|
516
|
+
const current = patchAttempt(store, updated, {
|
|
517
|
+
watcherAttachedAt: new Date().toISOString(),
|
|
518
|
+
});
|
|
519
|
+
if (current) {
|
|
520
|
+
appendLifecycle(current, 'watcher-attached');
|
|
521
|
+
}
|
|
522
|
+
} catch (error) {
|
|
523
|
+
const current = patchAttempt(store, updated, {
|
|
524
|
+
watcherError: error.message,
|
|
525
|
+
});
|
|
526
|
+
appendLifecycle(current || updated, 'watcher-attachment-failed', {
|
|
527
|
+
error: error.message,
|
|
528
|
+
});
|
|
529
|
+
return {
|
|
530
|
+
success: false,
|
|
531
|
+
error: `Launch accepted, but completion watcher attachment failed: ${error.message}`,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
485
535
|
|
|
486
536
|
return {
|
|
487
537
|
success: true,
|
|
@@ -75,6 +75,8 @@ class ExecutionRecord {
|
|
|
75
75
|
this.logPath = options.logPath || '';
|
|
76
76
|
this.startTime = options.startTime || new Date().toISOString();
|
|
77
77
|
this.endTime = options.endTime || null;
|
|
78
|
+
this.attempt = options.attempt;
|
|
79
|
+
this.attemptHistory = options.attemptHistory;
|
|
78
80
|
// Provenance of `endTime` (issue #170.2). `--status` used to fabricate a
|
|
79
81
|
// finish time with `new Date()` whenever the record had none, which made an
|
|
80
82
|
// observation indistinguishable from a real finish time. One of
|
|
@@ -152,6 +154,12 @@ class ExecutionRecord {
|
|
|
152
154
|
startTime: this.startTime,
|
|
153
155
|
endTime: this.endTime,
|
|
154
156
|
};
|
|
157
|
+
if (this.attempt) {
|
|
158
|
+
obj.attempt = this.attempt;
|
|
159
|
+
}
|
|
160
|
+
if (this.attemptHistory) {
|
|
161
|
+
obj.attemptHistory = this.attemptHistory;
|
|
162
|
+
}
|
|
155
163
|
if (this.endTimeSource !== undefined && this.endTimeSource !== null) {
|
|
156
164
|
obj.endTimeSource = this.endTimeSource;
|
|
157
165
|
}
|
|
@@ -557,6 +565,29 @@ class ExecutionStore {
|
|
|
557
565
|
}
|
|
558
566
|
}
|
|
559
567
|
|
|
568
|
+
// Merge watcher metadata under the database lock without replacing terminal state.
|
|
569
|
+
patchAttempt(uuid, number, fields) {
|
|
570
|
+
const lock = new LockManager(this.lockFilePath);
|
|
571
|
+
if (!lock.acquire()) {
|
|
572
|
+
throw new Error('Failed to acquire lock for attempt metadata');
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
const records = this.readLinoRecords();
|
|
576
|
+
const current = records.find((record) => record.uuid === uuid);
|
|
577
|
+
if (current?.attempt?.number !== number) {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
current.attempt = { ...current.attempt, ...fields };
|
|
581
|
+
this.writeLinoRecords(records);
|
|
582
|
+
if (this.useLinks) {
|
|
583
|
+
this.writeLinksRecord(current);
|
|
584
|
+
}
|
|
585
|
+
return current;
|
|
586
|
+
} finally {
|
|
587
|
+
lock.release();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
560
591
|
/**
|
|
561
592
|
* Get an execution record by UUID or session name
|
|
562
593
|
* First tries exact UUID match, then the current session name, then any
|