@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.
- package/dist/cli.js +55 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/daemon.js.map +1 -1
- package/dist/external-upgrade.d.ts +22 -0
- package/dist/external-upgrade.js +186 -0
- package/dist/external-upgrade.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/local-control-client.d.ts +4 -0
- package/dist/local-control-client.js.map +1 -1
- package/dist/local-control-http.js +28 -4
- package/dist/local-control-http.js.map +1 -1
- package/dist/local-control.d.ts +7 -2
- package/dist/local-control.js +12 -0
- package/dist/local-control.js.map +1 -1
- package/dist/process-identity.d.ts +30 -0
- package/dist/process-identity.js +99 -0
- package/dist/process-identity.js.map +1 -0
- package/dist/upgrade-job.d.ts +51 -0
- package/dist/upgrade-job.js +233 -20
- package/dist/upgrade-job.js.map +1 -1
- package/dist/upgrade-lock.d.ts +25 -0
- package/dist/upgrade-lock.js +207 -0
- package/dist/upgrade-lock.js.map +1 -0
- package/dist/upgrade-persistence.d.ts +1 -0
- package/dist/upgrade-persistence.js +25 -0
- package/dist/upgrade-persistence.js.map +1 -0
- package/dist/worker-host.d.ts +38 -0
- package/dist/worker-host.js +172 -0
- package/dist/worker-host.js.map +1 -0
- package/dist/worker-shutdown-authorization.d.ts +7 -0
- package/dist/worker-shutdown-authorization.js +71 -0
- package/dist/worker-shutdown-authorization.js.map +1 -0
- package/package.json +3 -2
- 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
|
+
});
|