@agent-link/agent 0.1.295 → 0.1.297

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 (39) hide show
  1. package/dist/cli.js +55 -1
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/daemon.js +1 -1
  6. package/dist/daemon.js.map +1 -1
  7. package/dist/external-upgrade.d.ts +22 -0
  8. package/dist/external-upgrade.js +186 -0
  9. package/dist/external-upgrade.js.map +1 -0
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.js +7 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/local-control-client.d.ts +4 -0
  14. package/dist/local-control-client.js.map +1 -1
  15. package/dist/local-control-http.js +28 -4
  16. package/dist/local-control-http.js.map +1 -1
  17. package/dist/local-control.d.ts +7 -2
  18. package/dist/local-control.js +12 -0
  19. package/dist/local-control.js.map +1 -1
  20. package/dist/process-identity.d.ts +30 -0
  21. package/dist/process-identity.js +99 -0
  22. package/dist/process-identity.js.map +1 -0
  23. package/dist/upgrade-job.d.ts +51 -0
  24. package/dist/upgrade-job.js +233 -20
  25. package/dist/upgrade-job.js.map +1 -1
  26. package/dist/upgrade-lock.d.ts +25 -0
  27. package/dist/upgrade-lock.js +207 -0
  28. package/dist/upgrade-lock.js.map +1 -0
  29. package/dist/upgrade-persistence.d.ts +1 -0
  30. package/dist/upgrade-persistence.js +25 -0
  31. package/dist/upgrade-persistence.js.map +1 -0
  32. package/dist/worker-host.d.ts +38 -0
  33. package/dist/worker-host.js +172 -0
  34. package/dist/worker-host.js.map +1 -0
  35. package/dist/worker-shutdown-authorization.d.ts +7 -0
  36. package/dist/worker-shutdown-authorization.js +71 -0
  37. package/dist/worker-shutdown-authorization.js.map +1 -0
  38. package/package.json +3 -2
  39. package/worker/worker.cjs +612 -0
@@ -0,0 +1,612 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const childProcess = require('child_process');
5
+ const crypto = require('crypto');
6
+ const fs = require('fs');
7
+ const http = require('http');
8
+ const path = require('path');
9
+
10
+ const PROTOCOL_VERSION = 1;
11
+ const PHASES = {
12
+ 'worker-starting': ['worker-ready', 'failed'],
13
+ 'worker-ready': ['shutdown-requested', 'failed'],
14
+ 'shutdown-requested': ['old-agent-stopped', 'failed'],
15
+ 'old-agent-stopped': ['switching', 'failed'],
16
+ switching: ['target-starting', 'failed'],
17
+ 'target-starting': ['target-started', 'failed'],
18
+ };
19
+
20
+ function fail(message) {
21
+ process.stderr.write(`[AgentLink Worker] ${message}\n`);
22
+ }
23
+
24
+ function atomicJson(file, value) {
25
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
26
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
27
+ const fd = fs.openSync(temporary, 'wx', 0o600);
28
+ try {
29
+ fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
30
+ fs.fsyncSync(fd);
31
+ } finally {
32
+ fs.closeSync(fd);
33
+ }
34
+ let lastError;
35
+ for (let attempt = 0; attempt < 5; attempt++) {
36
+ try {
37
+ fs.renameSync(temporary, file);
38
+ if (process.platform !== 'win32') fs.chmodSync(file, 0o600);
39
+ return;
40
+ } catch (error) {
41
+ lastError = error;
42
+ if (!['EACCES', 'EBUSY', 'EPERM'].includes(error.code) || attempt === 4) break;
43
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1));
44
+ }
45
+ }
46
+ fs.rmSync(temporary, { force: true });
47
+ throw lastError;
48
+ }
49
+
50
+ function loadJson(file) {
51
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
52
+ }
53
+
54
+ function loadJob(jobPath) {
55
+ const job = loadJson(jobPath);
56
+ if (
57
+ !job
58
+ || job.schemaVersion !== 2
59
+ || typeof job.jobId !== 'string'
60
+ || job.restartOwner !== 'worker'
61
+ || path.resolve(job.dataRoot) !== path.resolve(process.env.AGENTLINK_HOME || '')
62
+ ) {
63
+ throw new Error('job schema or data root is invalid');
64
+ }
65
+ return job;
66
+ }
67
+
68
+ function appendEvent(jobPath, job, code, detail) {
69
+ const event = {
70
+ timestamp: new Date().toISOString(),
71
+ jobId: job.jobId,
72
+ phase: job.phase,
73
+ code,
74
+ ...(detail ? { detail: String(detail).slice(0, 512) } : {}),
75
+ oldRuntime: {
76
+ pid: job.oldRuntime.pid,
77
+ processStartIdentity: job.oldRuntime.processStartIdentity,
78
+ },
79
+ ...(job.worker ? {
80
+ worker: {
81
+ pid: job.worker.pid,
82
+ processStartIdentity: job.worker.processStartIdentity,
83
+ },
84
+ } : {}),
85
+ };
86
+ fs.appendFileSync(path.join(path.dirname(jobPath), 'events.jsonl'), `${JSON.stringify(event)}\n`, {
87
+ encoding: 'utf8',
88
+ mode: 0o600,
89
+ });
90
+ }
91
+
92
+ function transition(jobPath, expectedJobId, phase, extra) {
93
+ const current = loadJob(jobPath);
94
+ if (current.jobId !== expectedJobId) throw new Error('job identity changed');
95
+ if (current.phase !== phase && !(PHASES[current.phase] || []).includes(phase)) {
96
+ throw new Error(`invalid worker transition ${current.phase} -> ${phase}`);
97
+ }
98
+ const updated = {
99
+ ...current,
100
+ ...extra,
101
+ phase,
102
+ updatedAt: new Date().toISOString(),
103
+ };
104
+ atomicJson(jobPath, updated);
105
+ atomicJson(path.join(updated.dataRoot, 'upgrades', 'latest.json'), updated);
106
+ appendEvent(jobPath, updated, `phase-${phase}`);
107
+ return updated;
108
+ }
109
+
110
+ function identityError(error) {
111
+ return error && error.message ? error.message : String(error);
112
+ }
113
+
114
+ function processIdentity(pid) {
115
+ if (process.platform === 'linux') {
116
+ let stat;
117
+ try {
118
+ stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim();
119
+ } catch (error) {
120
+ return error.code === 'ENOENT' || error.code === 'ESRCH'
121
+ ? { status: 'absent' }
122
+ : { status: 'error', error: identityError(error) };
123
+ }
124
+ try {
125
+ const commandEnd = stat.lastIndexOf(')');
126
+ if (commandEnd < 0) return { status: 'error', error: 'malformed Linux process stat' };
127
+ const fields = stat.slice(commandEnd + 2).split(/\s+/);
128
+ const bootId = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
129
+ return fields[19] && bootId
130
+ ? { status: 'found', identity: `linux:${bootId}:${fields[19]}` }
131
+ : { status: 'error', error: 'incomplete Linux process identity' };
132
+ } catch (error) {
133
+ return { status: 'error', error: identityError(error) };
134
+ }
135
+ }
136
+ if (process.platform === 'darwin') {
137
+ try {
138
+ const started = childProcess.execFileSync(
139
+ '/bin/ps',
140
+ ['-o', 'lstart=', '-p', String(pid)],
141
+ { encoding: 'utf8', timeout: 3000 },
142
+ ).trim();
143
+ return started
144
+ ? { status: 'found', identity: `darwin:${started}` }
145
+ : { status: 'absent' };
146
+ } catch (error) {
147
+ return error.status === 1 || error.code === 'ESRCH'
148
+ ? { status: 'absent' }
149
+ : { status: 'error', error: identityError(error) };
150
+ }
151
+ }
152
+ if (process.platform === 'win32') {
153
+ try {
154
+ const script = '& { param([int]$ProcessId) $p=Get-CimInstance Win32_Process -Filter ("ProcessId="+$ProcessId);if($p){$p.CreationDate.ToUniversalTime().ToString("O",[Globalization.CultureInfo]::InvariantCulture)} }';
155
+ const started = childProcess.execFileSync('powershell.exe', [
156
+ '-NoLogo',
157
+ '-NoProfile',
158
+ '-NonInteractive',
159
+ '-Command',
160
+ script,
161
+ '-ProcessId',
162
+ String(pid),
163
+ ], {
164
+ encoding: 'utf8',
165
+ windowsHide: true,
166
+ timeout: 3000,
167
+ }).trim();
168
+ return started
169
+ ? { status: 'found', identity: `win32:${started}` }
170
+ : { status: 'absent' };
171
+ } catch (error) {
172
+ return { status: 'error', error: identityError(error) };
173
+ }
174
+ }
175
+ return { status: 'error', error: `unsupported process identity platform: ${process.platform}` };
176
+ }
177
+
178
+ function matchProcessIdentity(pid, expected) {
179
+ const result = processIdentity(pid);
180
+ if (result.status !== 'found') return result;
181
+ return result.identity === expected
182
+ ? { status: 'match', identity: result.identity }
183
+ : { status: 'mismatch', identity: result.identity };
184
+ }
185
+
186
+ function assignLockWorker(job, worker) {
187
+ const lockDirectory = path.join(job.dataRoot, 'upgrades', 'active.lock');
188
+ const lockPath = path.join(lockDirectory, 'owner.json');
189
+ let owner = loadJson(lockPath);
190
+ if (
191
+ owner.jobId !== job.jobId
192
+ || owner.nonce !== job.lockNonce
193
+ || path.resolve(owner.dataRoot) !== path.resolve(job.dataRoot)
194
+ ) {
195
+ throw new Error('active upgrade lock does not belong to this job');
196
+ }
197
+ const claimPath = path.join(lockDirectory, 'handoff.claim');
198
+ const claimFd = fs.openSync(claimPath, 'wx', 0o600);
199
+ try {
200
+ fs.writeFileSync(claimFd, `${JSON.stringify({
201
+ schemaVersion: 1,
202
+ role: 'worker',
203
+ jobId: job.jobId,
204
+ nonce: job.lockNonce,
205
+ worker,
206
+ }, null, 2)}\n`, 'utf8');
207
+ fs.fsyncSync(claimFd);
208
+ } finally {
209
+ fs.closeSync(claimFd);
210
+ }
211
+ owner = loadJson(lockPath);
212
+ if (
213
+ owner.jobId !== job.jobId
214
+ || owner.nonce !== job.lockNonce
215
+ || path.resolve(owner.dataRoot) !== path.resolve(job.dataRoot)
216
+ ) {
217
+ throw new Error('active upgrade lock changed during worker handoff');
218
+ }
219
+ atomicJson(lockPath, { ...owner, worker });
220
+ }
221
+
222
+ function requestShutdown(runtime, job) {
223
+ const discovery = runtime.controlPlane;
224
+ if (
225
+ !discovery
226
+ || discovery.apiVersion !== 1
227
+ || discovery.host !== '127.0.0.1'
228
+ || !Array.isArray(discovery.capabilities)
229
+ || !discovery.capabilities.includes('upgrade.worker-shutdown')
230
+ ) {
231
+ return Promise.reject(new Error('old Agent does not advertise worker shutdown capability'));
232
+ }
233
+ const requestId = crypto.randomUUID();
234
+ const payload = JSON.stringify({
235
+ reason: 'worker-upgrade',
236
+ requestId,
237
+ expectedPid: job.oldRuntime.pid,
238
+ expectedProcessInstanceId: job.oldRuntime.processInstanceId,
239
+ jobId: job.jobId,
240
+ targetVersion: job.targetVersion,
241
+ lockNonce: job.lockNonce,
242
+ workerPid: job.worker.pid,
243
+ workerProcessStartIdentity: job.worker.processStartIdentity,
244
+ });
245
+ return new Promise((resolve, reject) => {
246
+ const request = http.request({
247
+ host: '127.0.0.1',
248
+ port: discovery.port,
249
+ path: '/v1/lifecycle/shutdown',
250
+ method: 'POST',
251
+ headers: {
252
+ Host: `127.0.0.1:${discovery.port}`,
253
+ Authorization: `Bearer ${discovery.token}`,
254
+ 'Content-Type': 'application/json',
255
+ 'Content-Length': Buffer.byteLength(payload),
256
+ 'X-Request-Id': requestId,
257
+ 'Idempotency-Key': requestId,
258
+ },
259
+ }, response => {
260
+ const chunks = [];
261
+ response.on('data', chunk => chunks.push(Buffer.from(chunk)));
262
+ response.on('end', () => {
263
+ const body = Buffer.concat(chunks).toString('utf8');
264
+ if (response.statusCode !== 202) {
265
+ reject(new Error(`old Agent rejected worker shutdown (${response.statusCode}): ${body.slice(0, 256)}`));
266
+ return;
267
+ }
268
+ resolve();
269
+ });
270
+ });
271
+ request.setTimeout(5000, () => request.destroy(new Error('worker shutdown request timed out')));
272
+ request.on('error', reject);
273
+ request.end(payload);
274
+ });
275
+ }
276
+
277
+ async function waitForOldRuntime(job, timeoutMs = 15000) {
278
+ const deadline = Date.now() + timeoutMs;
279
+ let lastLookupError = null;
280
+ while (Date.now() < deadline) {
281
+ const identity = matchProcessIdentity(
282
+ job.oldRuntime.pid,
283
+ job.oldRuntime.processStartIdentity,
284
+ );
285
+ if (identity.status === 'absent' || identity.status === 'mismatch') return;
286
+ if (identity.status === 'error') lastLookupError = identity.error;
287
+ await new Promise(resolve => setTimeout(resolve, 100));
288
+ }
289
+ if (lastLookupError) {
290
+ throw new Error(`could not verify old Agent exit: ${lastLookupError}`);
291
+ }
292
+ throw new Error('old Agent exact process identity did not exit before the handoff deadline');
293
+ }
294
+
295
+ function isInside(root, candidate) {
296
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
297
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
298
+ }
299
+
300
+ function validateSelection(job) {
301
+ const pointer = job.targetSelection;
302
+ const encoded = pointer.integrity.startsWith('sha512-')
303
+ ? pointer.integrity.slice('sha512-'.length)
304
+ : '';
305
+ const digest = Buffer.from(encoded, 'base64');
306
+ if (digest.length !== 64 || digest.toString('base64') !== encoded) {
307
+ throw new Error('target selection integrity is invalid');
308
+ }
309
+ const versionRoot = path.join(
310
+ job.dataRoot,
311
+ 'versions',
312
+ `${pointer.version}-${digest.toString('hex').slice(0, 16)}`,
313
+ );
314
+ const entry = fs.realpathSync(pointer.entry);
315
+ const canonicalRoot = fs.realpathSync(versionRoot);
316
+ if (!isInside(canonicalRoot, entry) || !fs.statSync(entry).isFile()) {
317
+ throw new Error('target selection entry escapes its immutable version directory');
318
+ }
319
+ const marker = loadJson(path.join(canonicalRoot, '.agentlink-version.json'));
320
+ if (
321
+ marker.schemaVersion !== 1
322
+ || marker.version !== pointer.version
323
+ || marker.integrity !== pointer.integrity
324
+ || path.resolve(canonicalRoot, marker.entry || '') !== entry
325
+ ) {
326
+ throw new Error('target selection marker does not match');
327
+ }
328
+ }
329
+
330
+ function isStrictlyNewerVersion(current, target) {
331
+ if (!/^\d+\.\d+\.\d+$/.test(current) || !/^\d+\.\d+\.\d+$/.test(target)) {
332
+ throw new Error('upgrade versions must use stable major.minor.patch');
333
+ }
334
+ const currentParts = current.split('.').map(Number);
335
+ const targetParts = target.split('.').map(Number);
336
+ for (let index = 0; index < 3; index++) {
337
+ if (targetParts[index] !== currentParts[index]) {
338
+ return targetParts[index] > currentParts[index];
339
+ }
340
+ }
341
+ return false;
342
+ }
343
+
344
+ function selectTarget(job) {
345
+ const current = loadJson(path.join(job.dataRoot, 'current-version.json'));
346
+ if (
347
+ current.version !== job.sourceSelection.version
348
+ || current.integrity !== job.sourceSelection.integrity
349
+ || path.resolve(current.entry) !== path.resolve(job.sourceSelection.entry)
350
+ ) {
351
+ throw new Error('current selection changed after worker handoff');
352
+ }
353
+ if (!isStrictlyNewerVersion(current.version, job.targetSelection.version)) {
354
+ throw new Error(
355
+ `target ${job.targetSelection.version} is not newer than current ${current.version}`,
356
+ );
357
+ }
358
+ validateSelection(job);
359
+ atomicJson(path.join(job.dataRoot, 'current-version.json'), job.targetSelection);
360
+ }
361
+
362
+ function waitForSpawn(child) {
363
+ return new Promise((resolve, reject) => {
364
+ const onError = error => reject(new Error(`target launcher spawn failed: ${error.message}`));
365
+ child.once('error', onError);
366
+ child.once('spawn', () => {
367
+ child.removeListener('error', onError);
368
+ resolve();
369
+ });
370
+ });
371
+ }
372
+
373
+ async function waitForTargetRuntime(job, child, timeoutMs = 15000) {
374
+ const runtimePath = path.join(job.dataRoot, 'agent.json');
375
+ const deadline = Date.now() + timeoutMs;
376
+ let earlyExit = null;
377
+ if (child) {
378
+ if (child.exitCode !== null || child.signalCode) {
379
+ earlyExit = { code: child.exitCode, signal: child.signalCode };
380
+ }
381
+ child.once('exit', (code, signal) => {
382
+ earlyExit = { code, signal };
383
+ });
384
+ }
385
+ while (Date.now() < deadline) {
386
+ if (!earlyExit && child && (child.exitCode !== null || child.signalCode)) {
387
+ earlyExit = { code: child.exitCode, signal: child.signalCode };
388
+ }
389
+ try {
390
+ const runtime = loadJson(runtimePath);
391
+ if (
392
+ runtime.upgradeJobId === job.jobId
393
+ && Number.isSafeInteger(runtime.pid)
394
+ && runtime.pid > 0
395
+ && runtime.pid !== job.oldRuntime.pid
396
+ ) {
397
+ const lookup = processIdentity(runtime.pid);
398
+ if (lookup.status === 'found') return;
399
+ if (lookup.status === 'error') {
400
+ fail(`target acknowledgement identity lookup delayed: ${lookup.error}`);
401
+ }
402
+ }
403
+ } catch (error) {
404
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
405
+ }
406
+ if (earlyExit?.signal || (earlyExit && earlyExit.code !== 0)) {
407
+ const status = earlyExit.signal ? `signal ${earlyExit.signal}` : `code ${earlyExit.code}`;
408
+ throw new Error(`target launcher exited before acknowledgement (${status})`);
409
+ }
410
+ await new Promise(resolve => setTimeout(resolve, 100));
411
+ }
412
+ throw new Error('target runtime did not acknowledge the upgrade job before the deadline');
413
+ }
414
+
415
+ async function startTarget(job) {
416
+ const launcher = path.join(job.dataRoot, 'launcher', 'launcher.cjs');
417
+ if (!fs.statSync(launcher).isFile()) throw new Error('stable launcher is not installed');
418
+ const hostType = loadJson(job.launchSpecPath).host.type;
419
+ const env = {
420
+ ...process.env,
421
+ AGENTLINK_HOME: job.dataRoot,
422
+ AGENTLINK_UPGRADE_JOB: job.jobId,
423
+ };
424
+ if (hostType === 'systemd') {
425
+ childProcess.execFileSync('systemctl', [
426
+ '--user',
427
+ 'set-environment',
428
+ `AGENTLINK_UPGRADE_JOB=${job.jobId}`,
429
+ ], { stdio: 'ignore', timeout: 5000 });
430
+ try {
431
+ childProcess.execFileSync('systemctl', ['--user', 'restart', 'agentlink.service'], {
432
+ stdio: 'ignore',
433
+ timeout: 10000,
434
+ });
435
+ } finally {
436
+ try {
437
+ childProcess.execFileSync('systemctl', [
438
+ '--user',
439
+ 'unset-environment',
440
+ 'AGENTLINK_UPGRADE_JOB',
441
+ ], { stdio: 'ignore', timeout: 5000 });
442
+ } catch (error) {
443
+ fail(`could not clear systemd upgrade environment: ${error.message}`);
444
+ }
445
+ }
446
+ await waitForTargetRuntime(job, null);
447
+ return;
448
+ }
449
+ if (hostType === 'launchd') {
450
+ const uid = process.getuid();
451
+ childProcess.execFileSync('launchctl', ['setenv', 'AGENTLINK_UPGRADE_JOB', job.jobId], {
452
+ stdio: 'ignore',
453
+ timeout: 5000,
454
+ });
455
+ try {
456
+ childProcess.execFileSync('launchctl', [
457
+ 'kickstart',
458
+ '-k',
459
+ `gui/${uid}/com.agentlink.agent`,
460
+ ], { stdio: 'ignore', timeout: 10000 });
461
+ } finally {
462
+ try {
463
+ childProcess.execFileSync('launchctl', ['unsetenv', 'AGENTLINK_UPGRADE_JOB'], {
464
+ stdio: 'ignore',
465
+ timeout: 5000,
466
+ });
467
+ } catch (error) {
468
+ fail(`could not clear launchd upgrade environment: ${error.message}`);
469
+ }
470
+ }
471
+ await waitForTargetRuntime(job, null);
472
+ return;
473
+ }
474
+ const targetLog = path.join(path.dirname(job.launchSpecPath), '..', 'logs', 'upgrade-target.log');
475
+ fs.mkdirSync(path.dirname(targetLog), { recursive: true, mode: 0o700 });
476
+ const output = fs.openSync(targetLog, 'a', 0o600);
477
+ let child;
478
+ try {
479
+ child = childProcess.spawn(process.execPath, [
480
+ launcher,
481
+ 'start',
482
+ '--launch-spec',
483
+ job.launchSpecPath,
484
+ '--upgrade-job',
485
+ job.jobId,
486
+ ], {
487
+ cwd: path.dirname(job.launchSpecPath),
488
+ env,
489
+ detached: true,
490
+ stdio: ['ignore', output, output],
491
+ windowsHide: true,
492
+ });
493
+ await waitForSpawn(child);
494
+ } finally {
495
+ fs.closeSync(output);
496
+ }
497
+ await waitForTargetRuntime(job, child);
498
+ child.unref();
499
+ }
500
+
501
+ function releaseLock(job, worker) {
502
+ const lockDirectory = path.join(job.dataRoot, 'upgrades', 'active.lock');
503
+ const owner = loadJson(path.join(lockDirectory, 'owner.json'));
504
+ const claim = loadJson(path.join(lockDirectory, 'handoff.claim'));
505
+ if (
506
+ owner.jobId !== job.jobId
507
+ || owner.nonce !== job.lockNonce
508
+ || owner.worker?.pid !== worker.pid
509
+ || owner.worker?.processStartIdentity !== worker.processStartIdentity
510
+ || claim.role !== 'worker'
511
+ || claim.jobId !== job.jobId
512
+ || claim.nonce !== job.lockNonce
513
+ || claim.worker?.pid !== worker.pid
514
+ || claim.worker?.processStartIdentity !== worker.processStartIdentity
515
+ ) {
516
+ throw new Error('cannot release an upgrade lock owned by another job');
517
+ }
518
+ const released = `${lockDirectory}.released-${job.lockNonce}`;
519
+ fs.renameSync(lockDirectory, released);
520
+ fs.rmSync(released, { recursive: true, force: true });
521
+ }
522
+
523
+ function cleanupLaunchdWorker(job) {
524
+ let hostType;
525
+ try {
526
+ hostType = loadJson(job.launchSpecPath).host.type;
527
+ } catch (error) {
528
+ fail(`could not determine worker host for cleanup: ${error.message}`);
529
+ return;
530
+ }
531
+ if (hostType !== 'launchd' || process.platform !== 'darwin') return;
532
+ const plistPath = path.join(path.dirname(job.launchSpecPath), '..', 'upgrades', job.jobId, 'worker.plist');
533
+ fs.rmSync(plistPath, { force: true });
534
+ const label = `com.agentlink.upgrade.${job.jobId}`;
535
+ try {
536
+ childProcess.execFileSync('launchctl', [
537
+ 'bootout',
538
+ `gui/${process.getuid()}/${label}`,
539
+ ], { stdio: 'ignore', timeout: 5000 });
540
+ } catch (error) {
541
+ fail(`could not bootout temporary launchd worker job: ${error.message}`);
542
+ }
543
+ }
544
+
545
+ async function main() {
546
+ const jobIndex = process.argv.indexOf('--job');
547
+ if (jobIndex < 0 || !process.argv[jobIndex + 1]) throw new Error('missing --job path');
548
+ const jobPath = path.resolve(process.argv[jobIndex + 1]);
549
+ let job = loadJob(jobPath);
550
+ if (job.phase !== 'worker-starting') throw new Error(`job is not worker-starting: ${job.phase}`);
551
+ const identityLookup = processIdentity(process.pid);
552
+ if (identityLookup.status !== 'found') {
553
+ throw new Error(
554
+ `cannot determine exact updater worker process identity: `
555
+ + (identityLookup.error || identityLookup.status),
556
+ );
557
+ }
558
+ const worker = {
559
+ pid: process.pid,
560
+ processStartIdentity: identityLookup.identity,
561
+ protocolVersion: PROTOCOL_VERSION,
562
+ };
563
+ 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);
567
+ if (
568
+ runtime.pid !== job.oldRuntime.pid
569
+ || runtime.processInstanceId !== job.oldRuntime.processInstanceId
570
+ ) {
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');
582
+ releaseLock(job, worker);
583
+ cleanupLaunchdWorker(job);
584
+ }
585
+
586
+ main().catch(error => {
587
+ fail(error.stack || error.message || String(error));
588
+ let failedJob = null;
589
+ try {
590
+ const jobIndex = process.argv.indexOf('--job');
591
+ const jobPath = jobIndex >= 0 ? path.resolve(process.argv[jobIndex + 1]) : null;
592
+ if (jobPath) {
593
+ 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
+ });
598
+ }
599
+ }
600
+ } catch (recordError) {
601
+ fail(`could not persist worker failure: ${recordError.message}`);
602
+ }
603
+ if (failedJob?.worker) {
604
+ try {
605
+ releaseLock(failedJob, failedJob.worker);
606
+ } catch (releaseError) {
607
+ fail(`could not release failed worker lock: ${releaseError.message}`);
608
+ }
609
+ }
610
+ if (failedJob) cleanupLaunchdWorker(failedJob);
611
+ process.exitCode = 1;
612
+ });