@agent-link/agent 0.1.299 → 0.1.300

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/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,428 @@ 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 (launchSpecDigest(job) !== job.launchSpecDigest) {
770
+ throw new Error('target LaunchSpec digest does not match the upgrade job');
771
+ }
772
+ const deadline = Date.now() + timeoutMs;
773
+ let lastError = 'target runtime was not published';
774
+ while (Date.now() < deadline) {
775
+ try {
776
+ const runtime = loadJson(path.join(job.dataRoot, 'agent.json'));
777
+ const actualIdentity = matchProcessIdentity(
778
+ runtime.pid,
779
+ expectedIdentity.processStartIdentity,
780
+ );
781
+ if (
782
+ runtime.pid !== expectedIdentity.pid
783
+ || runtime.processInstanceId !== expectedIdentity.processInstanceId
784
+ || actualIdentity.status !== 'match'
785
+ ) {
786
+ throw new Error('target exact process identity changed');
787
+ }
788
+ if (
789
+ runtime.versionStore?.active !== true
790
+ || runtime.versionStore.selectedVersion !== expectedSelection.version
791
+ || runtime.versionStore.selectedIntegrity !== expectedSelection.integrity
792
+ || runtime.upgradeJobId !== job.jobId
793
+ || path.resolve(runtime.launchSpecPath || '') !== path.resolve(job.launchSpecPath)
794
+ || runtime.launchSpecDigest !== job.launchSpecDigest
795
+ ) {
796
+ throw new Error('target runtime identity contract does not match the upgrade job');
797
+ }
798
+ const status = await callStatus(runtime);
799
+ if (
800
+ status.pid !== runtime.pid
801
+ || status.version !== expectedSelection.version
802
+ || status.shutdown?.phase !== 'idle'
803
+ || status.connection?.acceptingWork !== true
804
+ || status.connection?.backendReady !== true
805
+ || !sessionIdentityMatches(job, runtime, status)
806
+ ) {
807
+ throw new Error('target Local Control is not idle, accepting work, and backend-ready');
808
+ }
809
+ return { runtime, status, identity: expectedIdentity };
810
+ } catch (error) {
811
+ lastError = String(error.message || error);
812
+ }
813
+ await new Promise(resolve => setTimeout(resolve, 100));
814
+ }
815
+ throw new Error(`mandatory local health failed: ${lastError}`);
816
+ }
817
+
818
+ function relayHealthy(health) {
819
+ return health.status.connection.websocketConnected === true
820
+ && health.status.connection.relayReady === true
821
+ && health.status.connection.entraReady === true
822
+ && health.status.connection.sessionRestored === true;
823
+ }
824
+
825
+ async function waitForRelay(job, health) {
826
+ let current = health;
827
+ while (true) {
828
+ if (relayHealthy(current)) return current;
829
+ await new Promise(resolve => setTimeout(resolve, 1000));
830
+ current = await waitForLocalHealth(job, job.targetSelection, current.identity, 2000);
831
+ }
832
+ }
833
+
834
+ async function terminateExactRuntime(identity) {
835
+ const match = matchProcessIdentity(identity.pid, identity.processStartIdentity);
836
+ if (match.status === 'absent' || match.status === 'mismatch') {
837
+ if (process.platform !== 'win32') {
838
+ try {
839
+ process.kill(-identity.pid, 0);
840
+ throw new Error(
841
+ `exact launcher PID ${identity.pid} disappeared while its process group is still running`,
842
+ );
843
+ } catch (error) {
844
+ if (error.code !== 'ESRCH') throw error;
845
+ }
846
+ }
847
+ return;
848
+ }
849
+ if (match.status !== 'match') {
850
+ throw new Error(`cannot safely terminate target: ${match.error || match.status}`);
851
+ }
852
+
853
+ let unixSignalTarget = null;
854
+ if (process.platform === 'win32') {
855
+ try {
856
+ childProcess.execFileSync('taskkill.exe', ['/pid', String(identity.pid), '/t', '/f'], {
857
+ stdio: 'ignore',
858
+ windowsHide: true,
859
+ timeout: 10000,
860
+ });
861
+ } catch (error) {
862
+ const after = matchProcessIdentity(identity.pid, identity.processStartIdentity);
863
+ if (after.status === 'match' || after.status === 'error') throw error;
864
+ return;
865
+ }
866
+ } else {
867
+ let signalTarget = identity.pid;
868
+ try {
869
+ process.kill(-identity.pid, 0);
870
+ signalTarget = -identity.pid;
871
+ } catch (error) {
872
+ if (error.code !== 'ESRCH') throw error;
873
+ }
874
+ unixSignalTarget = signalTarget;
875
+ try { process.kill(signalTarget, 'SIGTERM'); } catch (error) {
876
+ if (error.code !== 'ESRCH') throw error;
877
+ }
878
+ const deadline = Date.now() + 5000;
879
+ while (Date.now() < deadline) {
880
+ if (signalTarget < 0) {
881
+ try {
882
+ process.kill(signalTarget, 0);
883
+ } catch (error) {
884
+ if (error.code === 'ESRCH') return;
885
+ throw error;
886
+ }
887
+ await new Promise(resolve => setTimeout(resolve, 100));
888
+ continue;
889
+ }
890
+ const state = matchProcessIdentity(identity.pid, identity.processStartIdentity);
891
+ if (state.status === 'absent' || state.status === 'mismatch') return;
892
+ await new Promise(resolve => setTimeout(resolve, 100));
893
+ }
894
+ if (signalTarget < 0) {
895
+ try { process.kill(signalTarget, 'SIGKILL'); } catch (error) {
896
+ if (error.code !== 'ESRCH') throw error;
897
+ }
898
+ } else {
899
+ const final = matchProcessIdentity(identity.pid, identity.processStartIdentity);
900
+ if (final.status === 'match') process.kill(identity.pid, 'SIGKILL');
901
+ }
902
+ }
903
+ const deadline = Date.now() + 5000;
904
+ while (Date.now() < deadline) {
905
+ if (unixSignalTarget && unixSignalTarget < 0) {
906
+ try {
907
+ process.kill(unixSignalTarget, 0);
908
+ } catch (error) {
909
+ if (error.code === 'ESRCH') return;
910
+ throw error;
911
+ }
912
+ await new Promise(resolve => setTimeout(resolve, 50));
913
+ continue;
914
+ }
915
+ const state = matchProcessIdentity(identity.pid, identity.processStartIdentity);
916
+ if (state.status === 'absent' || state.status === 'mismatch') return;
917
+ if (state.status === 'error') {
918
+ throw new Error(`cannot confirm terminated target identity: ${state.error}`);
919
+ }
920
+ await new Promise(resolve => setTimeout(resolve, 50));
921
+ }
922
+ if (unixSignalTarget && unixSignalTarget < 0) {
923
+ try {
924
+ process.kill(unixSignalTarget, 0);
925
+ throw new Error(`launcher process group ${-unixSignalTarget} did not exit after termination`);
926
+ } catch (error) {
927
+ if (error.code === 'ESRCH') return;
928
+ throw error;
929
+ }
930
+ }
931
+ const final = matchProcessIdentity(identity.pid, identity.processStartIdentity);
932
+ if (final.status === 'match') {
933
+ throw new Error(`target PID ${identity.pid} did not exit after termination`);
934
+ }
935
+ if (final.status === 'error') {
936
+ throw new Error(`cannot confirm terminated target identity: ${final.error}`);
937
+ }
938
+ }
939
+
940
+ async function terminateSpawnedLauncherTree(child) {
941
+ if (!child?.pid || child.exitCode !== null || child.signalCode) return;
942
+ const exited = new Promise(resolve => child.once('exit', resolve));
943
+ if (process.platform === 'win32') {
944
+ try {
945
+ childProcess.execFileSync('taskkill.exe', ['/pid', String(child.pid), '/t', '/f'], {
946
+ stdio: 'ignore',
947
+ windowsHide: true,
948
+ timeout: 10000,
949
+ });
950
+ } catch (error) {
951
+ if (child.exitCode === null && !child.signalCode) throw error;
952
+ }
953
+ } else {
954
+ try { process.kill(-child.pid, 'SIGTERM'); } catch (error) {
955
+ if (error.code !== 'ESRCH') throw error;
956
+ }
957
+ }
958
+ const exitedPromptly = await Promise.race([
959
+ exited.then(() => true),
960
+ new Promise(resolve => setTimeout(() => resolve(false), 5000)),
961
+ ]);
962
+ if (!exitedPromptly && process.platform !== 'win32') {
963
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (error) {
964
+ if (error.code !== 'ESRCH') throw error;
965
+ }
966
+ await exited;
967
+ } else if (!exitedPromptly) {
968
+ throw new Error('spawned launcher tree did not exit after forced termination');
969
+ }
970
+ }
971
+
972
+ function restoreLastKnownGood(job) {
973
+ validateSelection(job, job.lastKnownGoodSelection);
974
+ atomicJson(path.join(job.dataRoot, 'last-known-good.json'), job.lastKnownGoodSelection);
975
+ atomicJson(path.join(job.dataRoot, 'current-version.json'), job.lastKnownGoodSelection);
976
+ }
977
+
978
+ async function rollback(jobPath, job, worker, reason) {
979
+ if (job.phase !== 'rolling-back' && job.phase !== 'rollback-starting' && job.phase !== 'rollback-local-health') {
980
+ job = transition(jobPath, job.jobId, 'rolling-back', {
981
+ error: String(reason).slice(0, 512),
982
+ });
983
+ }
984
+ try {
985
+ if (job.phase === 'rolling-back') {
986
+ const targetIdentity = job.targetRuntime;
987
+ if (targetIdentity) await terminateExactRuntime(targetIdentity);
988
+ if (job.targetLauncher) await terminateExactRuntime(job.targetLauncher);
989
+ restoreLastKnownGood(job);
990
+ job = transition(jobPath, job.jobId, 'rollback-starting');
991
+ }
992
+ let acknowledged;
993
+ let health;
994
+ if (job.phase === 'rollback-starting') {
995
+ acknowledged = await startTarget(job);
996
+ health = await waitForLocalHealth(
997
+ job,
998
+ job.lastKnownGoodSelection,
999
+ acknowledged.identity,
1000
+ );
1001
+ job = transition(jobPath, job.jobId, 'rollback-local-health', {
1002
+ rollbackRuntime: acknowledged.identity,
1003
+ });
1004
+ } else {
1005
+ const runtime = loadJson(path.join(job.dataRoot, 'agent.json'));
1006
+ if (!job.rollbackRuntime) {
1007
+ throw new Error('rollback phase is missing its durable exact runtime identity');
1008
+ }
1009
+ const lookup = processIdentity(runtime.pid);
1010
+ if (lookup.status !== 'found') throw new Error('rollback runtime identity is unavailable');
1011
+ acknowledged = {
1012
+ runtime,
1013
+ identity: job.rollbackRuntime,
1014
+ };
1015
+ }
1016
+ if (!health) {
1017
+ health = await waitForLocalHealth(
1018
+ job,
1019
+ job.lastKnownGoodSelection,
1020
+ acknowledged.identity,
1021
+ );
1022
+ }
1023
+ job = transition(jobPath, job.jobId, 'rolled-back', {
1024
+ error: String(reason).slice(0, 512),
1025
+ });
1026
+ } catch (rollbackError) {
1027
+ job = loadJob(jobPath);
1028
+ const cleanupErrors = [];
1029
+ for (const identity of [job.rollbackRuntime, job.rollbackLauncher]) {
1030
+ if (!identity) continue;
1031
+ try {
1032
+ await terminateExactRuntime(identity);
1033
+ } catch (cleanupError) {
1034
+ cleanupErrors.push(String(cleanupError.message || cleanupError));
1035
+ }
1036
+ }
1037
+ if (cleanupErrors.length) {
1038
+ transition(jobPath, job.jobId, job.phase, {
1039
+ error: `rollback failure: ${String(rollbackError.message || rollbackError).slice(0, 220)}; rollback cleanup failure: ${cleanupErrors.join('; ').slice(0, 220)}`,
1040
+ });
1041
+ throw new Error(`rollback cleanup failed before lock release: ${cleanupErrors.join('; ')}`);
1042
+ }
1043
+ job = transition(jobPath, job.jobId, 'repair-required', {
1044
+ error: `target failure: ${String(reason).slice(0, 220)}; rollback failure: ${String(rollbackError.message || rollbackError).slice(0, 220)}`,
1045
+ });
1046
+ releaseLock(job, worker);
1047
+ cleanupLaunchdWorker(job);
1048
+ throw new Error(`rollback failed; local repair is required: ${rollbackError.message || rollbackError}`);
1049
+ }
1050
+ finalizeRolledBackJob(jobPath, job, worker);
1051
+ return job;
1052
+ }
1053
+
1054
+ function recordRolledBackFinalizationIssue(jobPath, job, step, error) {
1055
+ const detail = `${step} failed after rollback completed: ${identityError(error)}`;
1056
+ fail(detail);
1057
+ try {
1058
+ appendEvent(jobPath, job, 'rollback-finalization-failed', detail);
1059
+ } catch (recordError) {
1060
+ fail(`could not record rollback finalization failure: ${identityError(recordError)}`);
1061
+ }
1062
+ }
1063
+
1064
+ function finalizeRolledBackJob(jobPath, job, worker) {
1065
+ try {
1066
+ releaseLock(job, worker);
1067
+ } catch (error) {
1068
+ recordRolledBackFinalizationIssue(jobPath, job, 'upgrade lock release', error);
1069
+ }
1070
+ try {
1071
+ cleanupLaunchdWorker(job);
1072
+ } catch (error) {
1073
+ recordRolledBackFinalizationIssue(jobPath, job, 'worker host cleanup', error);
1074
+ }
499
1075
  }
500
1076
 
501
1077
  function releaseLock(job, worker) {
@@ -542,12 +1118,14 @@ function cleanupLaunchdWorker(job) {
542
1118
  }
543
1119
  }
544
1120
 
1121
+ let acquiredOwnership = false;
1122
+
545
1123
  async function main() {
546
1124
  const jobIndex = process.argv.indexOf('--job');
547
1125
  if (jobIndex < 0 || !process.argv[jobIndex + 1]) throw new Error('missing --job path');
548
1126
  const jobPath = path.resolve(process.argv[jobIndex + 1]);
549
1127
  let job = loadJob(jobPath);
550
- if (job.phase !== 'worker-starting') throw new Error(`job is not worker-starting: ${job.phase}`);
1128
+ if (TERMINAL_PHASES.has(job.phase)) return;
551
1129
  const identityLookup = processIdentity(process.pid);
552
1130
  if (identityLookup.status !== 'found') {
553
1131
  throw new Error(
@@ -561,46 +1139,153 @@ async function main() {
561
1139
  protocolVersion: PROTOCOL_VERSION,
562
1140
  };
563
1141
  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);
1142
+ acquiredOwnership = true;
1143
+ if (job.phase === 'created') {
1144
+ job = transition(jobPath, job.jobId, 'worker-starting', { worker, error: null });
1145
+ }
1146
+ if (job.phase === 'worker-starting') {
1147
+ job = transition(jobPath, job.jobId, 'worker-ready', { worker, error: null });
1148
+ } else {
1149
+ job = transition(jobPath, job.jobId, job.phase, { worker });
1150
+ }
1151
+
1152
+ if (job.phase === 'worker-ready') {
1153
+ const runtime = loadJson(path.join(job.dataRoot, 'agent.json'));
1154
+ if (
1155
+ runtime.pid !== job.oldRuntime.pid
1156
+ || runtime.processInstanceId !== job.oldRuntime.processInstanceId
1157
+ ) {
1158
+ const old = matchProcessIdentity(job.oldRuntime.pid, job.oldRuntime.processStartIdentity);
1159
+ if (old.status === 'absent' || old.status === 'mismatch') {
1160
+ job = transition(jobPath, job.jobId, 'shutdown-requested');
1161
+ } else {
1162
+ throw new Error('runtime state no longer identifies the expected old Agent');
1163
+ }
1164
+ } else {
1165
+ await requestShutdown(runtime, job);
1166
+ job = transition(jobPath, job.jobId, 'shutdown-requested');
1167
+ }
1168
+ }
1169
+ if (job.phase === 'shutdown-requested') {
1170
+ await waitForOldRuntime(job);
1171
+ job = transition(jobPath, job.jobId, 'old-agent-stopped');
1172
+ }
1173
+ if (job.phase === 'old-agent-stopped') {
1174
+ job = transition(jobPath, job.jobId, 'switching');
1175
+ }
1176
+ if (job.phase === 'switching') {
1177
+ selectTarget(job);
1178
+ job = transition(jobPath, job.jobId, 'target-starting');
1179
+ }
1180
+
567
1181
  if (
568
- runtime.pid !== job.oldRuntime.pid
569
- || runtime.processInstanceId !== job.oldRuntime.processInstanceId
1182
+ job.phase === 'rolling-back'
1183
+ || job.phase === 'rollback-starting'
1184
+ || job.phase === 'rollback-local-health'
570
1185
  ) {
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');
1186
+ await rollback(jobPath, job, worker, job.error || 'resuming interrupted rollback');
1187
+ return;
1188
+ }
1189
+
1190
+ let acknowledged;
1191
+ let health;
1192
+ if (job.phase === 'target-starting') {
1193
+ acknowledged = await startTarget(job);
1194
+ try {
1195
+ health = await waitForLocalHealth(job, job.targetSelection, acknowledged.identity);
1196
+ } catch (error) {
1197
+ await rollback(jobPath, loadJob(jobPath), worker, error.message || error);
1198
+ return;
1199
+ }
1200
+ job = transition(jobPath, job.jobId, 'target-local-health', {
1201
+ targetRuntime: acknowledged.identity,
1202
+ });
1203
+ } else if (job.phase === 'target-local-health' || job.phase === 'relay-pending') {
1204
+ if (!job.targetRuntime) {
1205
+ throw new Error('target health phase is missing its durable exact runtime identity');
1206
+ }
1207
+ const runtime = loadJson(path.join(job.dataRoot, 'agent.json'));
1208
+ const lookup = processIdentity(runtime.pid);
1209
+ if (lookup.status !== 'found') throw new Error('target process identity is unavailable');
1210
+ acknowledged = {
1211
+ runtime,
1212
+ identity: job.targetRuntime,
1213
+ };
1214
+ } else {
1215
+ throw new Error(`cannot recover external upgrade phase: ${job.phase}`);
1216
+ }
1217
+
1218
+ if (!health) {
1219
+ try {
1220
+ health = await waitForLocalHealth(job, job.targetSelection, acknowledged.identity);
1221
+ } catch (error) {
1222
+ await rollback(jobPath, loadJob(jobPath), worker, error.message || error);
1223
+ return;
1224
+ }
1225
+ }
1226
+ job = loadJob(jobPath);
1227
+ if (job.phase === 'target-local-health') {
1228
+ job = transition(jobPath, job.jobId, 'relay-pending');
1229
+ }
1230
+ health = await waitForRelay(job, health);
1231
+ atomicJson(path.join(job.dataRoot, 'last-known-good.json'), job.targetSelection);
1232
+ job = transition(jobPath, job.jobId, 'completed', { error: null });
582
1233
  releaseLock(job, worker);
583
1234
  cleanupLaunchdWorker(job);
584
1235
  }
585
1236
 
586
- main().catch(error => {
1237
+ main().catch(async error => {
587
1238
  fail(error.stack || error.message || String(error));
1239
+ if (!acquiredOwnership) {
1240
+ process.exitCode = 1;
1241
+ return;
1242
+ }
588
1243
  let failedJob = null;
589
1244
  try {
590
1245
  const jobIndex = process.argv.indexOf('--job');
591
1246
  const jobPath = jobIndex >= 0 ? path.resolve(process.argv[jobIndex + 1]) : null;
592
1247
  if (jobPath) {
593
1248
  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
- });
1249
+ if (!TERMINAL_PHASES.has(failedJob.phase)) {
1250
+ const beforeShutdown = [
1251
+ 'created',
1252
+ 'worker-starting',
1253
+ 'worker-ready',
1254
+ ].includes(failedJob.phase);
1255
+ if (beforeShutdown) {
1256
+ failedJob = transition(jobPath, failedJob.jobId, 'failed', {
1257
+ error: String(error.message || error).slice(0, 512),
1258
+ });
1259
+ } else if (failedJob.phase === 'shutdown-requested') {
1260
+ // The old Agent may already be draining. Leave this phase durable so
1261
+ // the stable launcher can reclaim the stale worker and continue.
1262
+ } else if (error.preventRollback) {
1263
+ failedJob = transition(jobPath, failedJob.jobId, 'repair-required', {
1264
+ error: String(error.message || error).slice(0, 512),
1265
+ });
1266
+ } else if (failedJob.worker) {
1267
+ try {
1268
+ failedJob = await rollback(
1269
+ jobPath,
1270
+ failedJob,
1271
+ failedJob.worker,
1272
+ String(error.message || error),
1273
+ );
1274
+ } catch (rollbackError) {
1275
+ fail(String(rollbackError.message || rollbackError));
1276
+ failedJob = loadJob(jobPath);
1277
+ }
1278
+ }
598
1279
  }
599
1280
  }
600
1281
  } catch (recordError) {
601
1282
  fail(`could not persist worker failure: ${recordError.message}`);
602
1283
  }
603
- if (failedJob?.worker) {
1284
+ if (
1285
+ failedJob?.worker
1286
+ && TERMINAL_PHASES.has(failedJob.phase)
1287
+ && fs.existsSync(path.join(failedJob.dataRoot, 'upgrades', 'active.lock'))
1288
+ ) {
604
1289
  try {
605
1290
  releaseLock(failedJob, failedJob.worker);
606
1291
  } catch (releaseError) {