@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.
Files changed (40) hide show
  1. package/dist/bootstrap-delegator.js +7 -1
  2. package/dist/bootstrap-delegator.js.map +1 -1
  3. package/dist/cli.js +55 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config.d.ts +8 -0
  6. package/dist/config.js +3 -20
  7. package/dist/config.js.map +1 -1
  8. package/dist/connection.d.ts +20 -0
  9. package/dist/connection.js +38 -3
  10. package/dist/connection.js.map +1 -1
  11. package/dist/external-upgrade.d.ts +1 -0
  12. package/dist/external-upgrade.js +13 -7
  13. package/dist/external-upgrade.js.map +1 -1
  14. package/dist/index.js +126 -8
  15. package/dist/index.js.map +1 -1
  16. package/dist/launcher-manager.d.ts +7 -1
  17. package/dist/launcher-manager.js +36 -2
  18. package/dist/launcher-manager.js.map +1 -1
  19. package/dist/local-control.d.ts +3 -0
  20. package/dist/local-control.js.map +1 -1
  21. package/dist/process-identity.js +1 -1
  22. package/dist/process-identity.js.map +1 -1
  23. package/dist/upgrade-job.d.ts +25 -1
  24. package/dist/upgrade-job.js +89 -8
  25. package/dist/upgrade-job.js.map +1 -1
  26. package/dist/upgrade-lock.d.ts +2 -1
  27. package/dist/upgrade-lock.js +51 -6
  28. package/dist/upgrade-lock.js.map +1 -1
  29. package/dist/upgrade-operations.d.ts +20 -0
  30. package/dist/upgrade-operations.js +538 -0
  31. package/dist/upgrade-operations.js.map +1 -0
  32. package/dist/worker-host.d.ts +1 -0
  33. package/dist/worker-host.js +14 -1
  34. package/dist/worker-host.js.map +1 -1
  35. package/dist/worker-upgrade-cli-result.d.ts +10 -0
  36. package/dist/worker-upgrade-cli-result.js +38 -0
  37. package/dist/worker-upgrade-cli-result.js.map +1 -0
  38. package/launcher/launcher.cjs +518 -4
  39. package/package.json +1 -1
  40. 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', 'failed'],
16
- switching: ['target-starting', 'failed'],
17
- 'target-starting': ['target-started', 'failed'],
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: 3000,
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 validateSelection(job) {
301
- const pointer = job.targetSelection;
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') return;
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
- await waitForTargetRuntime(job, null);
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
- await waitForTargetRuntime(job, null);
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
- await waitForSpawn(child);
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 (job.phase !== 'worker-starting') throw new Error(`job is not worker-starting: ${job.phase}`);
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
- job = transition(jobPath, job.jobId, 'worker-ready', { worker, error: null });
565
- const runtimePath = path.join(job.dataRoot, 'agent.json');
566
- const runtime = loadJson(runtimePath);
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
- runtime.pid !== job.oldRuntime.pid
569
- || runtime.processInstanceId !== job.oldRuntime.processInstanceId
1188
+ job.phase === 'rolling-back'
1189
+ || job.phase === 'rollback-starting'
1190
+ || job.phase === 'rollback-local-health'
570
1191
  ) {
571
- throw new Error('runtime state no longer identifies the expected old Agent');
572
- }
573
- await requestShutdown(runtime, job);
574
- job = transition(jobPath, job.jobId, 'shutdown-requested');
575
- await waitForOldRuntime(job);
576
- job = transition(jobPath, job.jobId, 'old-agent-stopped');
577
- job = transition(jobPath, job.jobId, 'switching');
578
- selectTarget(job);
579
- job = transition(jobPath, job.jobId, 'target-starting');
580
- await startTarget(job);
581
- job = transition(jobPath, job.jobId, 'target-started');
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 (failedJob.phase !== 'target-started' && failedJob.phase !== 'failed') {
595
- failedJob = transition(jobPath, failedJob.jobId, 'failed', {
596
- error: String(error.message || error).slice(0, 512),
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 (failedJob?.worker) {
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) {