@agent-link/agent 0.1.299 → 0.1.301
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/dist/bootstrap-delegator.js +7 -1
- package/dist/bootstrap-delegator.js.map +1 -1
- package/dist/cli.js +55 -2
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +3 -20
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +20 -0
- package/dist/connection.js +38 -3
- package/dist/connection.js.map +1 -1
- package/dist/external-upgrade.d.ts +1 -0
- package/dist/external-upgrade.js +13 -7
- package/dist/external-upgrade.js.map +1 -1
- package/dist/index.js +126 -8
- package/dist/index.js.map +1 -1
- package/dist/launcher-manager.d.ts +7 -1
- package/dist/launcher-manager.js +36 -2
- package/dist/launcher-manager.js.map +1 -1
- package/dist/local-control.d.ts +3 -0
- package/dist/local-control.js.map +1 -1
- package/dist/process-identity.js +1 -1
- package/dist/process-identity.js.map +1 -1
- package/dist/upgrade-job.d.ts +25 -1
- package/dist/upgrade-job.js +89 -8
- package/dist/upgrade-job.js.map +1 -1
- package/dist/upgrade-lock.d.ts +2 -1
- package/dist/upgrade-lock.js +51 -6
- package/dist/upgrade-lock.js.map +1 -1
- package/dist/upgrade-operations.d.ts +20 -0
- package/dist/upgrade-operations.js +538 -0
- package/dist/upgrade-operations.js.map +1 -0
- package/dist/worker-host.d.ts +1 -0
- package/dist/worker-host.js +14 -1
- package/dist/worker-host.js.map +1 -1
- package/dist/worker-upgrade-cli-result.d.ts +10 -0
- package/dist/worker-upgrade-cli-result.js +38 -0
- package/dist/worker-upgrade-cli-result.js.map +1 -0
- package/launcher/launcher.cjs +518 -4
- package/package.json +1 -1
- package/worker/worker.cjs +730 -39
package/worker/worker.cjs
CHANGED
|
@@ -9,13 +9,20 @@ const path = require('path');
|
|
|
9
9
|
|
|
10
10
|
const PROTOCOL_VERSION = 1;
|
|
11
11
|
const PHASES = {
|
|
12
|
+
created: ['worker-starting', 'failed'],
|
|
12
13
|
'worker-starting': ['worker-ready', 'failed'],
|
|
13
14
|
'worker-ready': ['shutdown-requested', 'failed'],
|
|
14
15
|
'shutdown-requested': ['old-agent-stopped', 'failed'],
|
|
15
|
-
'old-agent-stopped': ['switching', '
|
|
16
|
-
switching: ['target-starting', '
|
|
17
|
-
'target-starting': ['target-
|
|
16
|
+
'old-agent-stopped': ['switching', 'rolling-back', 'repair-required'],
|
|
17
|
+
switching: ['target-starting', 'rolling-back', 'repair-required'],
|
|
18
|
+
'target-starting': ['target-local-health', 'rolling-back', 'repair-required'],
|
|
19
|
+
'target-local-health': ['relay-pending', 'completed', 'rolling-back', 'repair-required'],
|
|
20
|
+
'relay-pending': ['completed', 'rolling-back', 'repair-required'],
|
|
21
|
+
'rolling-back': ['rollback-starting', 'repair-required'],
|
|
22
|
+
'rollback-starting': ['rollback-local-health', 'repair-required'],
|
|
23
|
+
'rollback-local-health': ['rolled-back', 'repair-required'],
|
|
18
24
|
};
|
|
25
|
+
const TERMINAL_PHASES = new Set(['completed', 'rolled-back', 'repair-required', 'failed']);
|
|
19
26
|
|
|
20
27
|
function fail(message) {
|
|
21
28
|
process.stderr.write(`[AgentLink Worker] ${message}\n`);
|
|
@@ -163,7 +170,7 @@ function processIdentity(pid) {
|
|
|
163
170
|
], {
|
|
164
171
|
encoding: 'utf8',
|
|
165
172
|
windowsHide: true,
|
|
166
|
-
timeout:
|
|
173
|
+
timeout: 10000,
|
|
167
174
|
}).trim();
|
|
168
175
|
return started
|
|
169
176
|
? { status: 'found', identity: `win32:${started}` }
|
|
@@ -195,6 +202,92 @@ function assignLockWorker(job, worker) {
|
|
|
195
202
|
throw new Error('active upgrade lock does not belong to this job');
|
|
196
203
|
}
|
|
197
204
|
const claimPath = path.join(lockDirectory, `handoff-${job.lockNonce}.claim`);
|
|
205
|
+
let reclaimFence = null;
|
|
206
|
+
if (fs.existsSync(claimPath)) {
|
|
207
|
+
const existingClaim = loadJson(claimPath);
|
|
208
|
+
if (
|
|
209
|
+
existingClaim.role !== 'worker'
|
|
210
|
+
|| existingClaim.jobId !== job.jobId
|
|
211
|
+
|| existingClaim.nonce !== job.lockNonce
|
|
212
|
+
|| !existingClaim.worker
|
|
213
|
+
) {
|
|
214
|
+
throw new Error('active upgrade lock has an incompatible handoff claim');
|
|
215
|
+
}
|
|
216
|
+
const existing = matchProcessIdentity(
|
|
217
|
+
existingClaim.worker.pid,
|
|
218
|
+
existingClaim.worker.processStartIdentity,
|
|
219
|
+
);
|
|
220
|
+
if (existing.status === 'match') {
|
|
221
|
+
throw new Error('the recorded updater worker is still running');
|
|
222
|
+
}
|
|
223
|
+
if (existing.status === 'error') {
|
|
224
|
+
throw new Error(`cannot safely reclaim updater worker claim: ${existing.error}`);
|
|
225
|
+
}
|
|
226
|
+
const claimGeneration = crypto.createHash('sha256')
|
|
227
|
+
.update(`${existingClaim.jobId}\0${existingClaim.nonce}\0${existingClaim.worker.pid}\0${existingClaim.worker.processStartIdentity}`)
|
|
228
|
+
.digest('hex')
|
|
229
|
+
.slice(0, 16);
|
|
230
|
+
reclaimFence = `${claimPath}.reclaim-${claimGeneration}`;
|
|
231
|
+
let fenceHandle;
|
|
232
|
+
while (fenceHandle === undefined) {
|
|
233
|
+
try {
|
|
234
|
+
fenceHandle = fs.openSync(reclaimFence, 'wx', 0o600);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error.code !== 'EEXIST') throw error;
|
|
237
|
+
let fenceOwner;
|
|
238
|
+
try {
|
|
239
|
+
fenceOwner = loadJson(reclaimFence);
|
|
240
|
+
} catch (fenceError) {
|
|
241
|
+
const age = Date.now() - fs.statSync(reclaimFence).mtimeMs;
|
|
242
|
+
if (age < 5000) {
|
|
243
|
+
throw new Error('another recovery worker is claiming the stale handoff');
|
|
244
|
+
}
|
|
245
|
+
fs.rmSync(reclaimFence, { force: true });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const fenceState = matchProcessIdentity(
|
|
249
|
+
fenceOwner.pid,
|
|
250
|
+
fenceOwner.processStartIdentity,
|
|
251
|
+
);
|
|
252
|
+
if (fenceState.status === 'match') {
|
|
253
|
+
throw new Error('another recovery worker is claiming the stale handoff');
|
|
254
|
+
}
|
|
255
|
+
if (fenceState.status === 'error') {
|
|
256
|
+
throw new Error(`cannot safely reclaim recovery fence: ${fenceState.error}`);
|
|
257
|
+
}
|
|
258
|
+
fs.rmSync(reclaimFence, { force: true });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
fs.writeFileSync(fenceHandle, `${JSON.stringify(worker, null, 2)}\n`, 'utf8');
|
|
263
|
+
fs.fsyncSync(fenceHandle);
|
|
264
|
+
} finally {
|
|
265
|
+
fs.closeSync(fenceHandle);
|
|
266
|
+
}
|
|
267
|
+
const confirmedFenceOwner = loadJson(reclaimFence);
|
|
268
|
+
if (
|
|
269
|
+
confirmedFenceOwner.pid !== worker.pid
|
|
270
|
+
|| confirmedFenceOwner.processStartIdentity !== worker.processStartIdentity
|
|
271
|
+
) {
|
|
272
|
+
throw new Error('recovery fence ownership changed before stale claim removal');
|
|
273
|
+
}
|
|
274
|
+
const currentClaim = loadJson(claimPath);
|
|
275
|
+
if (
|
|
276
|
+
currentClaim.role !== 'worker'
|
|
277
|
+
|| currentClaim.jobId !== existingClaim.jobId
|
|
278
|
+
|| currentClaim.nonce !== existingClaim.nonce
|
|
279
|
+
|| currentClaim.worker?.pid !== existingClaim.worker.pid
|
|
280
|
+
|| currentClaim.worker?.processStartIdentity !== existingClaim.worker.processStartIdentity
|
|
281
|
+
) {
|
|
282
|
+
throw new Error('stale handoff claim changed during recovery');
|
|
283
|
+
}
|
|
284
|
+
fs.rmSync(claimPath);
|
|
285
|
+
appendEvent(
|
|
286
|
+
path.join(job.dataRoot, 'upgrades', job.jobId, 'job.json'),
|
|
287
|
+
job,
|
|
288
|
+
'stale-worker-reclaimed',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
198
291
|
const claimFd = fs.openSync(claimPath, 'wx', 0o600);
|
|
199
292
|
try {
|
|
200
293
|
fs.writeFileSync(claimFd, `${JSON.stringify({
|
|
@@ -208,6 +301,7 @@ function assignLockWorker(job, worker) {
|
|
|
208
301
|
} finally {
|
|
209
302
|
fs.closeSync(claimFd);
|
|
210
303
|
}
|
|
304
|
+
if (reclaimFence) fs.rmSync(reclaimFence, { force: true });
|
|
211
305
|
owner = loadJson(lockPath);
|
|
212
306
|
if (
|
|
213
307
|
owner.jobId !== job.jobId
|
|
@@ -297,8 +391,20 @@ function isInside(root, candidate) {
|
|
|
297
391
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
298
392
|
}
|
|
299
393
|
|
|
300
|
-
function
|
|
301
|
-
const
|
|
394
|
+
function validateLaunchSpecPath(job) {
|
|
395
|
+
const canonicalRoot = fs.realpathSync(job.dataRoot);
|
|
396
|
+
const canonicalSpec = fs.realpathSync(job.launchSpecPath);
|
|
397
|
+
if (
|
|
398
|
+
path.resolve(job.dataRoot) !== canonicalRoot
|
|
399
|
+
|| path.resolve(job.launchSpecPath) !== canonicalSpec
|
|
400
|
+
|| !isInside(canonicalRoot, canonicalSpec)
|
|
401
|
+
|| !fs.statSync(canonicalSpec).isFile()
|
|
402
|
+
) {
|
|
403
|
+
throw new Error('LaunchSpec is relocated or escapes the trusted data root');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function validateSelection(job, pointer) {
|
|
302
408
|
const encoded = pointer.integrity.startsWith('sha512-')
|
|
303
409
|
? pointer.integrity.slice('sha512-'.length)
|
|
304
410
|
: '';
|
|
@@ -341,8 +447,16 @@ function isStrictlyNewerVersion(current, target) {
|
|
|
341
447
|
return false;
|
|
342
448
|
}
|
|
343
449
|
|
|
450
|
+
function sameSelection(left, right) {
|
|
451
|
+
return left
|
|
452
|
+
&& left.version === right.version
|
|
453
|
+
&& left.integrity === right.integrity
|
|
454
|
+
&& path.resolve(left.entry) === path.resolve(right.entry);
|
|
455
|
+
}
|
|
456
|
+
|
|
344
457
|
function selectTarget(job) {
|
|
345
458
|
const current = loadJson(path.join(job.dataRoot, 'current-version.json'));
|
|
459
|
+
if (sameSelection(current, job.targetSelection)) return;
|
|
346
460
|
if (
|
|
347
461
|
current.version !== job.sourceSelection.version
|
|
348
462
|
|| current.integrity !== job.sourceSelection.integrity
|
|
@@ -355,7 +469,7 @@ function selectTarget(job) {
|
|
|
355
469
|
`target ${job.targetSelection.version} is not newer than current ${current.version}`,
|
|
356
470
|
);
|
|
357
471
|
}
|
|
358
|
-
validateSelection(job);
|
|
472
|
+
validateSelection(job, job.targetSelection);
|
|
359
473
|
atomicJson(path.join(job.dataRoot, 'current-version.json'), job.targetSelection);
|
|
360
474
|
}
|
|
361
475
|
|
|
@@ -370,7 +484,7 @@ function waitForSpawn(child) {
|
|
|
370
484
|
});
|
|
371
485
|
}
|
|
372
486
|
|
|
373
|
-
async function waitForTargetRuntime(job, child, timeoutMs = 15000) {
|
|
487
|
+
async function waitForTargetRuntime(job, child, timeoutMs = Number(process.env.AGENTLINK_UPGRADE_HEALTH_TIMEOUT_MS) || 15000) {
|
|
374
488
|
const runtimePath = path.join(job.dataRoot, 'agent.json');
|
|
375
489
|
const deadline = Date.now() + timeoutMs;
|
|
376
490
|
let earlyExit = null;
|
|
@@ -392,10 +506,18 @@ async function waitForTargetRuntime(job, child, timeoutMs = 15000) {
|
|
|
392
506
|
runtime.upgradeJobId === job.jobId
|
|
393
507
|
&& Number.isSafeInteger(runtime.pid)
|
|
394
508
|
&& runtime.pid > 0
|
|
395
|
-
&& runtime.pid !== job.oldRuntime.pid
|
|
396
509
|
) {
|
|
397
510
|
const lookup = processIdentity(runtime.pid);
|
|
398
|
-
if (lookup.status === 'found')
|
|
511
|
+
if (lookup.status === 'found') {
|
|
512
|
+
return {
|
|
513
|
+
runtime,
|
|
514
|
+
identity: {
|
|
515
|
+
pid: runtime.pid,
|
|
516
|
+
processStartIdentity: lookup.identity,
|
|
517
|
+
processInstanceId: runtime.processInstanceId,
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
}
|
|
399
521
|
if (lookup.status === 'error') {
|
|
400
522
|
fail(`target acknowledgement identity lookup delayed: ${lookup.error}`);
|
|
401
523
|
}
|
|
@@ -413,6 +535,35 @@ async function waitForTargetRuntime(job, child, timeoutMs = 15000) {
|
|
|
413
535
|
}
|
|
414
536
|
|
|
415
537
|
async function startTarget(job) {
|
|
538
|
+
validateLaunchSpecPath(job);
|
|
539
|
+
const isRollback = job.phase === 'rollback-starting';
|
|
540
|
+
const expectedRuntime = isRollback
|
|
541
|
+
? job.rollbackRuntime
|
|
542
|
+
: job.targetRuntime;
|
|
543
|
+
try {
|
|
544
|
+
const existing = loadJson(path.join(job.dataRoot, 'agent.json'));
|
|
545
|
+
if (
|
|
546
|
+
expectedRuntime
|
|
547
|
+
&& existing.upgradeJobId === job.jobId
|
|
548
|
+
&& existing.pid === expectedRuntime.pid
|
|
549
|
+
&& existing.processInstanceId === expectedRuntime.processInstanceId
|
|
550
|
+
) {
|
|
551
|
+
const identity = matchProcessIdentity(
|
|
552
|
+
existing.pid,
|
|
553
|
+
expectedRuntime.processStartIdentity,
|
|
554
|
+
);
|
|
555
|
+
if (identity.status === 'match') {
|
|
556
|
+
return {
|
|
557
|
+
runtime: existing,
|
|
558
|
+
identity: expectedRuntime,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
// A replacement has not published its runtime yet.
|
|
564
|
+
}
|
|
565
|
+
const previousLauncher = isRollback ? job.rollbackLauncher : job.targetLauncher;
|
|
566
|
+
if (previousLauncher) await terminateExactRuntime(previousLauncher);
|
|
416
567
|
const launcher = path.join(job.dataRoot, 'launcher', 'launcher.cjs');
|
|
417
568
|
if (!fs.statSync(launcher).isFile()) throw new Error('stable launcher is not installed');
|
|
418
569
|
const hostType = loadJson(job.launchSpecPath).host.type;
|
|
@@ -420,12 +571,14 @@ async function startTarget(job) {
|
|
|
420
571
|
...process.env,
|
|
421
572
|
AGENTLINK_HOME: job.dataRoot,
|
|
422
573
|
AGENTLINK_UPGRADE_JOB: job.jobId,
|
|
574
|
+
AGENTLINK_RECOVERY_OWNER: 'worker',
|
|
423
575
|
};
|
|
424
576
|
if (hostType === 'systemd') {
|
|
425
577
|
childProcess.execFileSync('systemctl', [
|
|
426
578
|
'--user',
|
|
427
579
|
'set-environment',
|
|
428
580
|
`AGENTLINK_UPGRADE_JOB=${job.jobId}`,
|
|
581
|
+
'AGENTLINK_RECOVERY_OWNER=worker',
|
|
429
582
|
], { stdio: 'ignore', timeout: 5000 });
|
|
430
583
|
try {
|
|
431
584
|
childProcess.execFileSync('systemctl', ['--user', 'restart', 'agentlink.service'], {
|
|
@@ -438,13 +591,13 @@ async function startTarget(job) {
|
|
|
438
591
|
'--user',
|
|
439
592
|
'unset-environment',
|
|
440
593
|
'AGENTLINK_UPGRADE_JOB',
|
|
594
|
+
'AGENTLINK_RECOVERY_OWNER',
|
|
441
595
|
], { stdio: 'ignore', timeout: 5000 });
|
|
442
596
|
} catch (error) {
|
|
443
597
|
fail(`could not clear systemd upgrade environment: ${error.message}`);
|
|
444
598
|
}
|
|
445
599
|
}
|
|
446
|
-
|
|
447
|
-
return;
|
|
600
|
+
return waitForTargetRuntime(job, null);
|
|
448
601
|
}
|
|
449
602
|
if (hostType === 'launchd') {
|
|
450
603
|
const uid = process.getuid();
|
|
@@ -452,6 +605,10 @@ async function startTarget(job) {
|
|
|
452
605
|
stdio: 'ignore',
|
|
453
606
|
timeout: 5000,
|
|
454
607
|
});
|
|
608
|
+
childProcess.execFileSync('launchctl', ['setenv', 'AGENTLINK_RECOVERY_OWNER', 'worker'], {
|
|
609
|
+
stdio: 'ignore',
|
|
610
|
+
timeout: 5000,
|
|
611
|
+
});
|
|
455
612
|
try {
|
|
456
613
|
childProcess.execFileSync('launchctl', [
|
|
457
614
|
'kickstart',
|
|
@@ -464,12 +621,15 @@ async function startTarget(job) {
|
|
|
464
621
|
stdio: 'ignore',
|
|
465
622
|
timeout: 5000,
|
|
466
623
|
});
|
|
624
|
+
childProcess.execFileSync('launchctl', ['unsetenv', 'AGENTLINK_RECOVERY_OWNER'], {
|
|
625
|
+
stdio: 'ignore',
|
|
626
|
+
timeout: 5000,
|
|
627
|
+
});
|
|
467
628
|
} catch (error) {
|
|
468
629
|
fail(`could not clear launchd upgrade environment: ${error.message}`);
|
|
469
630
|
}
|
|
470
631
|
}
|
|
471
|
-
|
|
472
|
-
return;
|
|
632
|
+
return waitForTargetRuntime(job, null);
|
|
473
633
|
}
|
|
474
634
|
const targetLog = path.join(path.dirname(job.launchSpecPath), '..', 'logs', 'upgrade-target.log');
|
|
475
635
|
fs.mkdirSync(path.dirname(targetLog), { recursive: true, mode: 0o700 });
|
|
@@ -490,12 +650,434 @@ async function startTarget(job) {
|
|
|
490
650
|
stdio: ['ignore', output, output],
|
|
491
651
|
windowsHide: true,
|
|
492
652
|
});
|
|
493
|
-
|
|
653
|
+
try {
|
|
654
|
+
await waitForSpawn(child);
|
|
655
|
+
const launcherIdentity = processIdentity(child.pid);
|
|
656
|
+
if (launcherIdentity.status !== 'found') {
|
|
657
|
+
throw new Error(
|
|
658
|
+
`cannot determine exact target launcher identity: ${launcherIdentity.error || launcherIdentity.status}`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
job = transition(
|
|
662
|
+
path.join(job.dataRoot, 'upgrades', job.jobId, 'job.json'),
|
|
663
|
+
job.jobId,
|
|
664
|
+
job.phase,
|
|
665
|
+
{
|
|
666
|
+
[isRollback ? 'rollbackLauncher' : 'targetLauncher']: {
|
|
667
|
+
pid: child.pid,
|
|
668
|
+
processStartIdentity: launcherIdentity.identity,
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
);
|
|
672
|
+
} catch (error) {
|
|
673
|
+
try {
|
|
674
|
+
await terminateSpawnedLauncherTree(child);
|
|
675
|
+
} catch (cleanupError) {
|
|
676
|
+
const fatal = new Error(
|
|
677
|
+
`${error.message || error}; spawned launcher cleanup failed: `
|
|
678
|
+
+ `${cleanupError.message || cleanupError}`,
|
|
679
|
+
);
|
|
680
|
+
fatal.preventRollback = true;
|
|
681
|
+
throw fatal;
|
|
682
|
+
}
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
494
685
|
} finally {
|
|
495
686
|
fs.closeSync(output);
|
|
496
687
|
}
|
|
497
|
-
await waitForTargetRuntime(job, child);
|
|
688
|
+
const result = await waitForTargetRuntime(job, child);
|
|
498
689
|
child.unref();
|
|
690
|
+
return result;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function callStatus(runtime, timeoutMs = 1500) {
|
|
694
|
+
const discovery = runtime.controlPlane;
|
|
695
|
+
if (
|
|
696
|
+
!discovery
|
|
697
|
+
|| discovery.apiVersion !== 1
|
|
698
|
+
|| discovery.host !== '127.0.0.1'
|
|
699
|
+
|| !Number.isSafeInteger(discovery.port)
|
|
700
|
+
|| typeof discovery.token !== 'string'
|
|
701
|
+
) {
|
|
702
|
+
return Promise.reject(new Error('target did not publish valid Local Control discovery'));
|
|
703
|
+
}
|
|
704
|
+
return new Promise((resolve, reject) => {
|
|
705
|
+
const request = http.request({
|
|
706
|
+
host: '127.0.0.1',
|
|
707
|
+
port: discovery.port,
|
|
708
|
+
path: '/v1/status',
|
|
709
|
+
method: 'GET',
|
|
710
|
+
headers: {
|
|
711
|
+
Host: `127.0.0.1:${discovery.port}`,
|
|
712
|
+
Authorization: `Bearer ${discovery.token}`,
|
|
713
|
+
'X-Request-Id': crypto.randomUUID(),
|
|
714
|
+
},
|
|
715
|
+
}, response => {
|
|
716
|
+
const chunks = [];
|
|
717
|
+
let size = 0;
|
|
718
|
+
response.on('data', chunk => {
|
|
719
|
+
size += chunk.length;
|
|
720
|
+
if (size > 64 * 1024) request.destroy(new Error('Local Control response exceeded 64 KiB'));
|
|
721
|
+
else chunks.push(Buffer.from(chunk));
|
|
722
|
+
});
|
|
723
|
+
response.on('end', () => {
|
|
724
|
+
try {
|
|
725
|
+
const envelope = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
726
|
+
if (response.statusCode !== 200 || !envelope.ok || !envelope.data) {
|
|
727
|
+
reject(new Error(`Local Control status failed (${response.statusCode || 0})`));
|
|
728
|
+
} else {
|
|
729
|
+
resolve(envelope.data);
|
|
730
|
+
}
|
|
731
|
+
} catch {
|
|
732
|
+
reject(new Error('Local Control returned invalid status JSON'));
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
request.setTimeout(timeoutMs, () => request.destroy(new Error('Local Control status timed out')));
|
|
737
|
+
request.on('error', reject);
|
|
738
|
+
request.end();
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function launchSpecDigest(job) {
|
|
743
|
+
validateLaunchSpecPath(job);
|
|
744
|
+
return crypto.createHash('sha256')
|
|
745
|
+
.update(JSON.stringify(loadJson(job.launchSpecPath)))
|
|
746
|
+
.digest('hex');
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function sessionUrlHash(runtime) {
|
|
750
|
+
return crypto.createHash('sha256').update(runtime.sessionUrl || '').digest('hex');
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function sessionIdentityMatches(job, runtime, status) {
|
|
754
|
+
const preserved = runtime.sessionId === job.oldRuntime.sessionId
|
|
755
|
+
&& sessionUrlHash(runtime) === job.oldRuntime.sessionUrlHash
|
|
756
|
+
&& status.connection?.sessionId === job.oldRuntime.sessionId;
|
|
757
|
+
if (preserved) return true;
|
|
758
|
+
const registration = runtime.sessionRegistration;
|
|
759
|
+
return registration
|
|
760
|
+
&& registration.previousSessionId === job.oldRuntime.sessionId
|
|
761
|
+
&& registration.sessionId === runtime.sessionId
|
|
762
|
+
&& registration.sessionUrlHash === sessionUrlHash(runtime)
|
|
763
|
+
&& status.connection?.sessionId === runtime.sessionId
|
|
764
|
+
&& Number.isFinite(Date.parse(registration.registeredAt));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async function waitForLocalHealth(job, expectedSelection, expectedIdentity, timeoutMs = Number(process.env.AGENTLINK_UPGRADE_HEALTH_TIMEOUT_MS) || 15000) {
|
|
768
|
+
validateSelection(job, expectedSelection);
|
|
769
|
+
if (
|
|
770
|
+
process.env.AGENTLINK_WORKER_TEST_FAIL_LOCAL_HEALTH_VERSION
|
|
771
|
+
=== expectedSelection.version
|
|
772
|
+
) {
|
|
773
|
+
throw new Error(`injected local health failure for ${expectedSelection.version}`);
|
|
774
|
+
}
|
|
775
|
+
if (launchSpecDigest(job) !== job.launchSpecDigest) {
|
|
776
|
+
throw new Error('target LaunchSpec digest does not match the upgrade job');
|
|
777
|
+
}
|
|
778
|
+
const deadline = Date.now() + timeoutMs;
|
|
779
|
+
let lastError = 'target runtime was not published';
|
|
780
|
+
while (Date.now() < deadline) {
|
|
781
|
+
try {
|
|
782
|
+
const runtime = loadJson(path.join(job.dataRoot, 'agent.json'));
|
|
783
|
+
const actualIdentity = matchProcessIdentity(
|
|
784
|
+
runtime.pid,
|
|
785
|
+
expectedIdentity.processStartIdentity,
|
|
786
|
+
);
|
|
787
|
+
if (
|
|
788
|
+
runtime.pid !== expectedIdentity.pid
|
|
789
|
+
|| runtime.processInstanceId !== expectedIdentity.processInstanceId
|
|
790
|
+
|| actualIdentity.status !== 'match'
|
|
791
|
+
) {
|
|
792
|
+
throw new Error('target exact process identity changed');
|
|
793
|
+
}
|
|
794
|
+
if (
|
|
795
|
+
runtime.versionStore?.active !== true
|
|
796
|
+
|| runtime.versionStore.selectedVersion !== expectedSelection.version
|
|
797
|
+
|| runtime.versionStore.selectedIntegrity !== expectedSelection.integrity
|
|
798
|
+
|| runtime.upgradeJobId !== job.jobId
|
|
799
|
+
|| path.resolve(runtime.launchSpecPath || '') !== path.resolve(job.launchSpecPath)
|
|
800
|
+
|| runtime.launchSpecDigest !== job.launchSpecDigest
|
|
801
|
+
) {
|
|
802
|
+
throw new Error('target runtime identity contract does not match the upgrade job');
|
|
803
|
+
}
|
|
804
|
+
const status = await callStatus(runtime);
|
|
805
|
+
if (
|
|
806
|
+
status.pid !== runtime.pid
|
|
807
|
+
|| status.version !== expectedSelection.version
|
|
808
|
+
|| status.shutdown?.phase !== 'idle'
|
|
809
|
+
|| status.connection?.acceptingWork !== true
|
|
810
|
+
|| status.connection?.backendReady !== true
|
|
811
|
+
|| !sessionIdentityMatches(job, runtime, status)
|
|
812
|
+
) {
|
|
813
|
+
throw new Error('target Local Control is not idle, accepting work, and backend-ready');
|
|
814
|
+
}
|
|
815
|
+
return { runtime, status, identity: expectedIdentity };
|
|
816
|
+
} catch (error) {
|
|
817
|
+
lastError = String(error.message || error);
|
|
818
|
+
}
|
|
819
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
820
|
+
}
|
|
821
|
+
throw new Error(`mandatory local health failed: ${lastError}`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function relayHealthy(health) {
|
|
825
|
+
return health.status.connection.websocketConnected === true
|
|
826
|
+
&& health.status.connection.relayReady === true
|
|
827
|
+
&& health.status.connection.entraReady === true
|
|
828
|
+
&& health.status.connection.sessionRestored === true;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function waitForRelay(job, health) {
|
|
832
|
+
let current = health;
|
|
833
|
+
while (true) {
|
|
834
|
+
if (relayHealthy(current)) return current;
|
|
835
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
836
|
+
current = await waitForLocalHealth(job, job.targetSelection, current.identity, 2000);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function terminateExactRuntime(identity) {
|
|
841
|
+
const match = matchProcessIdentity(identity.pid, identity.processStartIdentity);
|
|
842
|
+
if (match.status === 'absent' || match.status === 'mismatch') {
|
|
843
|
+
if (process.platform !== 'win32') {
|
|
844
|
+
try {
|
|
845
|
+
process.kill(-identity.pid, 0);
|
|
846
|
+
throw new Error(
|
|
847
|
+
`exact launcher PID ${identity.pid} disappeared while its process group is still running`,
|
|
848
|
+
);
|
|
849
|
+
} catch (error) {
|
|
850
|
+
if (error.code !== 'ESRCH') throw error;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (match.status !== 'match') {
|
|
856
|
+
throw new Error(`cannot safely terminate target: ${match.error || match.status}`);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
let unixSignalTarget = null;
|
|
860
|
+
if (process.platform === 'win32') {
|
|
861
|
+
try {
|
|
862
|
+
childProcess.execFileSync('taskkill.exe', ['/pid', String(identity.pid), '/t', '/f'], {
|
|
863
|
+
stdio: 'ignore',
|
|
864
|
+
windowsHide: true,
|
|
865
|
+
timeout: 10000,
|
|
866
|
+
});
|
|
867
|
+
} catch (error) {
|
|
868
|
+
const after = matchProcessIdentity(identity.pid, identity.processStartIdentity);
|
|
869
|
+
if (after.status === 'match' || after.status === 'error') throw error;
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
} else {
|
|
873
|
+
let signalTarget = identity.pid;
|
|
874
|
+
try {
|
|
875
|
+
process.kill(-identity.pid, 0);
|
|
876
|
+
signalTarget = -identity.pid;
|
|
877
|
+
} catch (error) {
|
|
878
|
+
if (error.code !== 'ESRCH') throw error;
|
|
879
|
+
}
|
|
880
|
+
unixSignalTarget = signalTarget;
|
|
881
|
+
try { process.kill(signalTarget, 'SIGTERM'); } catch (error) {
|
|
882
|
+
if (error.code !== 'ESRCH') throw error;
|
|
883
|
+
}
|
|
884
|
+
const deadline = Date.now() + 5000;
|
|
885
|
+
while (Date.now() < deadline) {
|
|
886
|
+
if (signalTarget < 0) {
|
|
887
|
+
try {
|
|
888
|
+
process.kill(signalTarget, 0);
|
|
889
|
+
} catch (error) {
|
|
890
|
+
if (error.code === 'ESRCH') return;
|
|
891
|
+
throw error;
|
|
892
|
+
}
|
|
893
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
const state = matchProcessIdentity(identity.pid, identity.processStartIdentity);
|
|
897
|
+
if (state.status === 'absent' || state.status === 'mismatch') return;
|
|
898
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
899
|
+
}
|
|
900
|
+
if (signalTarget < 0) {
|
|
901
|
+
try { process.kill(signalTarget, 'SIGKILL'); } catch (error) {
|
|
902
|
+
if (error.code !== 'ESRCH') throw error;
|
|
903
|
+
}
|
|
904
|
+
} else {
|
|
905
|
+
const final = matchProcessIdentity(identity.pid, identity.processStartIdentity);
|
|
906
|
+
if (final.status === 'match') process.kill(identity.pid, 'SIGKILL');
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
const deadline = Date.now() + 5000;
|
|
910
|
+
while (Date.now() < deadline) {
|
|
911
|
+
if (unixSignalTarget && unixSignalTarget < 0) {
|
|
912
|
+
try {
|
|
913
|
+
process.kill(unixSignalTarget, 0);
|
|
914
|
+
} catch (error) {
|
|
915
|
+
if (error.code === 'ESRCH') return;
|
|
916
|
+
throw error;
|
|
917
|
+
}
|
|
918
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const state = matchProcessIdentity(identity.pid, identity.processStartIdentity);
|
|
922
|
+
if (state.status === 'absent' || state.status === 'mismatch') return;
|
|
923
|
+
if (state.status === 'error') {
|
|
924
|
+
throw new Error(`cannot confirm terminated target identity: ${state.error}`);
|
|
925
|
+
}
|
|
926
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
927
|
+
}
|
|
928
|
+
if (unixSignalTarget && unixSignalTarget < 0) {
|
|
929
|
+
try {
|
|
930
|
+
process.kill(unixSignalTarget, 0);
|
|
931
|
+
throw new Error(`launcher process group ${-unixSignalTarget} did not exit after termination`);
|
|
932
|
+
} catch (error) {
|
|
933
|
+
if (error.code === 'ESRCH') return;
|
|
934
|
+
throw error;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const final = matchProcessIdentity(identity.pid, identity.processStartIdentity);
|
|
938
|
+
if (final.status === 'match') {
|
|
939
|
+
throw new Error(`target PID ${identity.pid} did not exit after termination`);
|
|
940
|
+
}
|
|
941
|
+
if (final.status === 'error') {
|
|
942
|
+
throw new Error(`cannot confirm terminated target identity: ${final.error}`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function terminateSpawnedLauncherTree(child) {
|
|
947
|
+
if (!child?.pid || child.exitCode !== null || child.signalCode) return;
|
|
948
|
+
const exited = new Promise(resolve => child.once('exit', resolve));
|
|
949
|
+
if (process.platform === 'win32') {
|
|
950
|
+
try {
|
|
951
|
+
childProcess.execFileSync('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], {
|
|
952
|
+
stdio: 'ignore',
|
|
953
|
+
windowsHide: true,
|
|
954
|
+
timeout: 10000,
|
|
955
|
+
});
|
|
956
|
+
} catch (error) {
|
|
957
|
+
if (child.exitCode === null && !child.signalCode) throw error;
|
|
958
|
+
}
|
|
959
|
+
} else {
|
|
960
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch (error) {
|
|
961
|
+
if (error.code !== 'ESRCH') throw error;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
const exitedPromptly = await Promise.race([
|
|
965
|
+
exited.then(() => true),
|
|
966
|
+
new Promise(resolve => setTimeout(() => resolve(false), 5000)),
|
|
967
|
+
]);
|
|
968
|
+
if (!exitedPromptly && process.platform !== 'win32') {
|
|
969
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch (error) {
|
|
970
|
+
if (error.code !== 'ESRCH') throw error;
|
|
971
|
+
}
|
|
972
|
+
await exited;
|
|
973
|
+
} else if (!exitedPromptly) {
|
|
974
|
+
throw new Error('spawned launcher tree did not exit after forced termination');
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function restoreLastKnownGood(job) {
|
|
979
|
+
validateSelection(job, job.lastKnownGoodSelection);
|
|
980
|
+
atomicJson(path.join(job.dataRoot, 'last-known-good.json'), job.lastKnownGoodSelection);
|
|
981
|
+
atomicJson(path.join(job.dataRoot, 'current-version.json'), job.lastKnownGoodSelection);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function rollback(jobPath, job, worker, reason) {
|
|
985
|
+
if (job.phase !== 'rolling-back' && job.phase !== 'rollback-starting' && job.phase !== 'rollback-local-health') {
|
|
986
|
+
job = transition(jobPath, job.jobId, 'rolling-back', {
|
|
987
|
+
error: String(reason).slice(0, 512),
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
try {
|
|
991
|
+
if (job.phase === 'rolling-back') {
|
|
992
|
+
const targetIdentity = job.targetRuntime;
|
|
993
|
+
if (targetIdentity) await terminateExactRuntime(targetIdentity);
|
|
994
|
+
if (job.targetLauncher) await terminateExactRuntime(job.targetLauncher);
|
|
995
|
+
restoreLastKnownGood(job);
|
|
996
|
+
job = transition(jobPath, job.jobId, 'rollback-starting');
|
|
997
|
+
}
|
|
998
|
+
let acknowledged;
|
|
999
|
+
let health;
|
|
1000
|
+
if (job.phase === 'rollback-starting') {
|
|
1001
|
+
acknowledged = await startTarget(job);
|
|
1002
|
+
health = await waitForLocalHealth(
|
|
1003
|
+
job,
|
|
1004
|
+
job.lastKnownGoodSelection,
|
|
1005
|
+
acknowledged.identity,
|
|
1006
|
+
);
|
|
1007
|
+
job = transition(jobPath, job.jobId, 'rollback-local-health', {
|
|
1008
|
+
rollbackRuntime: acknowledged.identity,
|
|
1009
|
+
});
|
|
1010
|
+
} else {
|
|
1011
|
+
const runtime = loadJson(path.join(job.dataRoot, 'agent.json'));
|
|
1012
|
+
if (!job.rollbackRuntime) {
|
|
1013
|
+
throw new Error('rollback phase is missing its durable exact runtime identity');
|
|
1014
|
+
}
|
|
1015
|
+
const lookup = processIdentity(runtime.pid);
|
|
1016
|
+
if (lookup.status !== 'found') throw new Error('rollback runtime identity is unavailable');
|
|
1017
|
+
acknowledged = {
|
|
1018
|
+
runtime,
|
|
1019
|
+
identity: job.rollbackRuntime,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
if (!health) {
|
|
1023
|
+
health = await waitForLocalHealth(
|
|
1024
|
+
job,
|
|
1025
|
+
job.lastKnownGoodSelection,
|
|
1026
|
+
acknowledged.identity,
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
job = transition(jobPath, job.jobId, 'rolled-back', {
|
|
1030
|
+
error: String(reason).slice(0, 512),
|
|
1031
|
+
});
|
|
1032
|
+
} catch (rollbackError) {
|
|
1033
|
+
job = loadJob(jobPath);
|
|
1034
|
+
const cleanupErrors = [];
|
|
1035
|
+
for (const identity of [job.rollbackRuntime, job.rollbackLauncher]) {
|
|
1036
|
+
if (!identity) continue;
|
|
1037
|
+
try {
|
|
1038
|
+
await terminateExactRuntime(identity);
|
|
1039
|
+
} catch (cleanupError) {
|
|
1040
|
+
cleanupErrors.push(String(cleanupError.message || cleanupError));
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (cleanupErrors.length) {
|
|
1044
|
+
transition(jobPath, job.jobId, job.phase, {
|
|
1045
|
+
error: `rollback failure: ${String(rollbackError.message || rollbackError).slice(0, 220)}; rollback cleanup failure: ${cleanupErrors.join('; ').slice(0, 220)}`,
|
|
1046
|
+
});
|
|
1047
|
+
throw new Error(`rollback cleanup failed before lock release: ${cleanupErrors.join('; ')}`);
|
|
1048
|
+
}
|
|
1049
|
+
job = transition(jobPath, job.jobId, 'repair-required', {
|
|
1050
|
+
error: `target failure: ${String(reason).slice(0, 220)}; rollback failure: ${String(rollbackError.message || rollbackError).slice(0, 220)}`,
|
|
1051
|
+
});
|
|
1052
|
+
releaseLock(job, worker);
|
|
1053
|
+
cleanupLaunchdWorker(job);
|
|
1054
|
+
throw new Error(`rollback failed; local repair is required: ${rollbackError.message || rollbackError}`);
|
|
1055
|
+
}
|
|
1056
|
+
finalizeRolledBackJob(jobPath, job, worker);
|
|
1057
|
+
return job;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function recordRolledBackFinalizationIssue(jobPath, job, step, error) {
|
|
1061
|
+
const detail = `${step} failed after rollback completed: ${identityError(error)}`;
|
|
1062
|
+
fail(detail);
|
|
1063
|
+
try {
|
|
1064
|
+
appendEvent(jobPath, job, 'rollback-finalization-failed', detail);
|
|
1065
|
+
} catch (recordError) {
|
|
1066
|
+
fail(`could not record rollback finalization failure: ${identityError(recordError)}`);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function finalizeRolledBackJob(jobPath, job, worker) {
|
|
1071
|
+
try {
|
|
1072
|
+
releaseLock(job, worker);
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
recordRolledBackFinalizationIssue(jobPath, job, 'upgrade lock release', error);
|
|
1075
|
+
}
|
|
1076
|
+
try {
|
|
1077
|
+
cleanupLaunchdWorker(job);
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
recordRolledBackFinalizationIssue(jobPath, job, 'worker host cleanup', error);
|
|
1080
|
+
}
|
|
499
1081
|
}
|
|
500
1082
|
|
|
501
1083
|
function releaseLock(job, worker) {
|
|
@@ -542,12 +1124,14 @@ function cleanupLaunchdWorker(job) {
|
|
|
542
1124
|
}
|
|
543
1125
|
}
|
|
544
1126
|
|
|
1127
|
+
let acquiredOwnership = false;
|
|
1128
|
+
|
|
545
1129
|
async function main() {
|
|
546
1130
|
const jobIndex = process.argv.indexOf('--job');
|
|
547
1131
|
if (jobIndex < 0 || !process.argv[jobIndex + 1]) throw new Error('missing --job path');
|
|
548
1132
|
const jobPath = path.resolve(process.argv[jobIndex + 1]);
|
|
549
1133
|
let job = loadJob(jobPath);
|
|
550
|
-
if (
|
|
1134
|
+
if (TERMINAL_PHASES.has(job.phase)) return;
|
|
551
1135
|
const identityLookup = processIdentity(process.pid);
|
|
552
1136
|
if (identityLookup.status !== 'found') {
|
|
553
1137
|
throw new Error(
|
|
@@ -561,46 +1145,153 @@ async function main() {
|
|
|
561
1145
|
protocolVersion: PROTOCOL_VERSION,
|
|
562
1146
|
};
|
|
563
1147
|
assignLockWorker(job, worker);
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1148
|
+
acquiredOwnership = true;
|
|
1149
|
+
if (job.phase === 'created') {
|
|
1150
|
+
job = transition(jobPath, job.jobId, 'worker-starting', { worker, error: null });
|
|
1151
|
+
}
|
|
1152
|
+
if (job.phase === 'worker-starting') {
|
|
1153
|
+
job = transition(jobPath, job.jobId, 'worker-ready', { worker, error: null });
|
|
1154
|
+
} else {
|
|
1155
|
+
job = transition(jobPath, job.jobId, job.phase, { worker });
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (job.phase === 'worker-ready') {
|
|
1159
|
+
const runtime = loadJson(path.join(job.dataRoot, 'agent.json'));
|
|
1160
|
+
if (
|
|
1161
|
+
runtime.pid !== job.oldRuntime.pid
|
|
1162
|
+
|| runtime.processInstanceId !== job.oldRuntime.processInstanceId
|
|
1163
|
+
) {
|
|
1164
|
+
const old = matchProcessIdentity(job.oldRuntime.pid, job.oldRuntime.processStartIdentity);
|
|
1165
|
+
if (old.status === 'absent' || old.status === 'mismatch') {
|
|
1166
|
+
job = transition(jobPath, job.jobId, 'shutdown-requested');
|
|
1167
|
+
} else {
|
|
1168
|
+
throw new Error('runtime state no longer identifies the expected old Agent');
|
|
1169
|
+
}
|
|
1170
|
+
} else {
|
|
1171
|
+
await requestShutdown(runtime, job);
|
|
1172
|
+
job = transition(jobPath, job.jobId, 'shutdown-requested');
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (job.phase === 'shutdown-requested') {
|
|
1176
|
+
await waitForOldRuntime(job);
|
|
1177
|
+
job = transition(jobPath, job.jobId, 'old-agent-stopped');
|
|
1178
|
+
}
|
|
1179
|
+
if (job.phase === 'old-agent-stopped') {
|
|
1180
|
+
job = transition(jobPath, job.jobId, 'switching');
|
|
1181
|
+
}
|
|
1182
|
+
if (job.phase === 'switching') {
|
|
1183
|
+
selectTarget(job);
|
|
1184
|
+
job = transition(jobPath, job.jobId, 'target-starting');
|
|
1185
|
+
}
|
|
1186
|
+
|
|
567
1187
|
if (
|
|
568
|
-
|
|
569
|
-
||
|
|
1188
|
+
job.phase === 'rolling-back'
|
|
1189
|
+
|| job.phase === 'rollback-starting'
|
|
1190
|
+
|| job.phase === 'rollback-local-health'
|
|
570
1191
|
) {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
1192
|
+
await rollback(jobPath, job, worker, job.error || 'resuming interrupted rollback');
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
let acknowledged;
|
|
1197
|
+
let health;
|
|
1198
|
+
if (job.phase === 'target-starting') {
|
|
1199
|
+
acknowledged = await startTarget(job);
|
|
1200
|
+
try {
|
|
1201
|
+
health = await waitForLocalHealth(job, job.targetSelection, acknowledged.identity);
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
await rollback(jobPath, loadJob(jobPath), worker, error.message || error);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
job = transition(jobPath, job.jobId, 'target-local-health', {
|
|
1207
|
+
targetRuntime: acknowledged.identity,
|
|
1208
|
+
});
|
|
1209
|
+
} else if (job.phase === 'target-local-health' || job.phase === 'relay-pending') {
|
|
1210
|
+
if (!job.targetRuntime) {
|
|
1211
|
+
throw new Error('target health phase is missing its durable exact runtime identity');
|
|
1212
|
+
}
|
|
1213
|
+
const runtime = loadJson(path.join(job.dataRoot, 'agent.json'));
|
|
1214
|
+
const lookup = processIdentity(runtime.pid);
|
|
1215
|
+
if (lookup.status !== 'found') throw new Error('target process identity is unavailable');
|
|
1216
|
+
acknowledged = {
|
|
1217
|
+
runtime,
|
|
1218
|
+
identity: job.targetRuntime,
|
|
1219
|
+
};
|
|
1220
|
+
} else {
|
|
1221
|
+
throw new Error(`cannot recover external upgrade phase: ${job.phase}`);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (!health) {
|
|
1225
|
+
try {
|
|
1226
|
+
health = await waitForLocalHealth(job, job.targetSelection, acknowledged.identity);
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
await rollback(jobPath, loadJob(jobPath), worker, error.message || error);
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
job = loadJob(jobPath);
|
|
1233
|
+
if (job.phase === 'target-local-health') {
|
|
1234
|
+
job = transition(jobPath, job.jobId, 'relay-pending');
|
|
1235
|
+
}
|
|
1236
|
+
health = await waitForRelay(job, health);
|
|
1237
|
+
atomicJson(path.join(job.dataRoot, 'last-known-good.json'), job.targetSelection);
|
|
1238
|
+
job = transition(jobPath, job.jobId, 'completed', { error: null });
|
|
582
1239
|
releaseLock(job, worker);
|
|
583
1240
|
cleanupLaunchdWorker(job);
|
|
584
1241
|
}
|
|
585
1242
|
|
|
586
|
-
main().catch(error => {
|
|
1243
|
+
main().catch(async error => {
|
|
587
1244
|
fail(error.stack || error.message || String(error));
|
|
1245
|
+
if (!acquiredOwnership) {
|
|
1246
|
+
process.exitCode = 1;
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
588
1249
|
let failedJob = null;
|
|
589
1250
|
try {
|
|
590
1251
|
const jobIndex = process.argv.indexOf('--job');
|
|
591
1252
|
const jobPath = jobIndex >= 0 ? path.resolve(process.argv[jobIndex + 1]) : null;
|
|
592
1253
|
if (jobPath) {
|
|
593
1254
|
failedJob = loadJob(jobPath);
|
|
594
|
-
if (
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1255
|
+
if (!TERMINAL_PHASES.has(failedJob.phase)) {
|
|
1256
|
+
const beforeShutdown = [
|
|
1257
|
+
'created',
|
|
1258
|
+
'worker-starting',
|
|
1259
|
+
'worker-ready',
|
|
1260
|
+
].includes(failedJob.phase);
|
|
1261
|
+
if (beforeShutdown) {
|
|
1262
|
+
failedJob = transition(jobPath, failedJob.jobId, 'failed', {
|
|
1263
|
+
error: String(error.message || error).slice(0, 512),
|
|
1264
|
+
});
|
|
1265
|
+
} else if (failedJob.phase === 'shutdown-requested') {
|
|
1266
|
+
// The old Agent may already be draining. Leave this phase durable so
|
|
1267
|
+
// the stable launcher can reclaim the stale worker and continue.
|
|
1268
|
+
} else if (error.preventRollback) {
|
|
1269
|
+
failedJob = transition(jobPath, failedJob.jobId, 'repair-required', {
|
|
1270
|
+
error: String(error.message || error).slice(0, 512),
|
|
1271
|
+
});
|
|
1272
|
+
} else if (failedJob.worker) {
|
|
1273
|
+
try {
|
|
1274
|
+
failedJob = await rollback(
|
|
1275
|
+
jobPath,
|
|
1276
|
+
failedJob,
|
|
1277
|
+
failedJob.worker,
|
|
1278
|
+
String(error.message || error),
|
|
1279
|
+
);
|
|
1280
|
+
} catch (rollbackError) {
|
|
1281
|
+
fail(String(rollbackError.message || rollbackError));
|
|
1282
|
+
failedJob = loadJob(jobPath);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
598
1285
|
}
|
|
599
1286
|
}
|
|
600
1287
|
} catch (recordError) {
|
|
601
1288
|
fail(`could not persist worker failure: ${recordError.message}`);
|
|
602
1289
|
}
|
|
603
|
-
if (
|
|
1290
|
+
if (
|
|
1291
|
+
failedJob?.worker
|
|
1292
|
+
&& TERMINAL_PHASES.has(failedJob.phase)
|
|
1293
|
+
&& fs.existsSync(path.join(failedJob.dataRoot, 'upgrades', 'active.lock'))
|
|
1294
|
+
) {
|
|
604
1295
|
try {
|
|
605
1296
|
releaseLock(failedJob, failedJob.worker);
|
|
606
1297
|
} catch (releaseError) {
|