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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.35.3",
3
+ "version": "0.35.4",
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": {
@@ -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
- FATAL_MARKER_TAIL_BYTES,
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
- ? readLogTail(record.logPath, FATAL_MARKER_TAIL_BYTES)
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
- parts.push(`docker logs -f${since} ${quotedName} >> ${quotedLogPath} 2>&1`);
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(buildDetachedFinalizeSnippet(executionId));
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
- { since, recoverOnKill: true }
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(record, plan, containerId) {
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 = new Date().toISOString();
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
- const updated = applyResumeToRecord(record, plan, containerId);
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