@yeaft/webchat-agent 1.0.341 → 1.0.342

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.341",
3
+ "version": "1.0.342",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1,9 +1,12 @@
1
- import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
3
  import { delimiter, dirname, join, win32 } from 'node:path';
3
4
  import { setTimeout as delay } from 'node:timers/promises';
4
5
 
5
6
  export const DEFAULT_UPGRADE_REGISTRY = 'https://pkg.yeaft.com/';
6
7
 
8
+ const WINDOWS_UPGRADE_LOCK_NAME = 'active.lock';
9
+
7
10
  const ONLINE_METADATA_FLAGS = [
8
11
  '--prefer-online',
9
12
  '--prefer-offline=false',
@@ -71,13 +74,73 @@ export function buildUpgradeInstallCommand(packageSpec, options) {
71
74
  return ['npm', ...buildUpgradeInstallArgs(packageSpec, options)].join(' ');
72
75
  }
73
76
 
74
- /** Build a shell-free detached Node invocation for the Windows updater. */
75
- export function buildWindowsUpgradeInvocation({ nodePath, runnerPath, payloadPath, logPath }) {
77
+ /** Allocate one isolated runtime directory and atomically lock the instance. */
78
+ export function createWindowsUpgradeRun(upgradeRoot, {
79
+ runId = randomUUID(),
80
+ parentPid = process.pid,
81
+ } = {}) {
82
+ if (!/^[A-Za-z0-9._-]+$/u.test(runId)) throw new TypeError('runId contains invalid path characters');
83
+ if (!Number.isInteger(parentPid) || parentPid <= 0) throw new TypeError('parentPid must be a positive integer');
84
+ const lockPath = join(upgradeRoot, WINDOWS_UPGRADE_LOCK_NAME);
85
+ mkdirSync(upgradeRoot, { recursive: true });
86
+
87
+ try {
88
+ mkdirSync(lockPath);
89
+ } catch (err) {
90
+ if (err?.code !== 'EEXIST') throw err;
91
+ let owner = null;
92
+ try { owner = JSON.parse(readFileSync(join(lockPath, 'owner.json'), 'utf8')); } catch {}
93
+ const detail = /^[A-Za-z0-9._-]+$/u.test(owner?.runId || '') ? ` (run ${owner.runId})` : '';
94
+ throw new Error(`A Windows upgrade lock already exists for this instance${detail} at ${lockPath}; remove it only after confirming no upgrade is running`);
95
+ }
96
+
97
+ const runDir = join(upgradeRoot, 'runs', runId);
98
+ try {
99
+ writeFileSync(join(lockPath, 'owner.json'), JSON.stringify({ runId, parentPid, createdAt: Date.now() }));
100
+ mkdirSync(dirname(runDir), { recursive: true });
101
+ mkdirSync(runDir);
102
+ return {
103
+ runId,
104
+ runDir,
105
+ lockPath,
106
+ bootstrapPath: join(runDir, 'windows-upgrade-bootstrap.js'),
107
+ runnerPath: join(runDir, 'windows-upgrade-runner.js'),
108
+ commandPath: join(runDir, 'upgrade-command.js'),
109
+ payloadPath: join(runDir, 'payload.json'),
110
+ handoffPath: join(runDir, 'started'),
111
+ authorizePath: join(runDir, 'authorized'),
112
+ cancelPath: join(runDir, 'cancelled'),
113
+ };
114
+ } catch (err) {
115
+ try { rmSync(lockPath, { recursive: true, force: true }); } catch {}
116
+ throw err;
117
+ }
118
+ }
119
+
120
+ /** Release the instance lock only when it still belongs to this run. */
121
+ export function releaseWindowsUpgradeLock(lockPath, runId) {
122
+ if (!lockPath || !runId) return false;
123
+ let owner;
124
+ try {
125
+ owner = JSON.parse(readFileSync(join(lockPath, 'owner.json'), 'utf8'));
126
+ } catch {
127
+ return false;
128
+ }
129
+ if (owner?.runId !== runId) return false;
130
+ try {
131
+ rmSync(lockPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /** Build a shell-free Node invocation for the short-lived bootstrap. */
139
+ export function buildWindowsUpgradeInvocation({ nodePath, bootstrapPath, runnerPath, payloadPath, logPath }) {
76
140
  return {
77
141
  command: nodePath,
78
- args: [runnerPath, payloadPath],
142
+ args: [bootstrapPath, runnerPath, payloadPath],
79
143
  options: {
80
- detached: true,
81
144
  stdio: 'ignore',
82
145
  windowsHide: true,
83
146
  env: { ...process.env, YEAFT_UPGRADE_LOG: logPath },
@@ -85,17 +148,33 @@ export function buildWindowsUpgradeInvocation({ nodePath, runnerPath, payloadPat
85
148
  };
86
149
  }
87
150
 
88
- /**
89
- * Copy the updater out of the npm package before npm replaces that package.
90
- * The runner imports only this helper module, so copy both files together.
91
- */
92
- export function prepareWindowsUpgradeRunner({ sourceRunnerPath, sourceCommandPath, runnerPath, commandPath, payloadPath, payload }) {
151
+ /** Copy the bootstrap and updater out of the package before npm replaces it. */
152
+ export function prepareWindowsUpgradeRunner({
153
+ sourceBootstrapPath,
154
+ sourceRunnerPath,
155
+ sourceCommandPath,
156
+ bootstrapPath,
157
+ runnerPath,
158
+ commandPath,
159
+ payloadPath,
160
+ payload,
161
+ }) {
93
162
  const runtimeDir = dirname(runnerPath);
94
163
  const moduleManifestPath = join(runtimeDir, 'package.json');
95
164
  mkdirSync(runtimeDir, { recursive: true });
96
- for (const path of [runnerPath, commandPath, moduleManifestPath, payloadPath, payload.handoffPath]) {
165
+ for (const path of [
166
+ bootstrapPath,
167
+ runnerPath,
168
+ commandPath,
169
+ moduleManifestPath,
170
+ payloadPath,
171
+ payload.handoffPath,
172
+ payload.authorizePath,
173
+ payload.cancelPath,
174
+ ]) {
97
175
  try { rmSync(path, { force: true }); } catch {}
98
176
  }
177
+ copyFileSync(sourceBootstrapPath, bootstrapPath);
99
178
  copyFileSync(sourceRunnerPath, runnerPath);
100
179
  copyFileSync(sourceCommandPath, commandPath);
101
180
  writeFileSync(moduleManifestPath, JSON.stringify({ type: 'module' }));
@@ -117,94 +196,163 @@ function waitForSpawn(child) {
117
196
  });
118
197
  }
119
198
 
199
+ function waitForExit(child, timeoutMs) {
200
+ return new Promise((resolve, reject) => {
201
+ if (child.exitCode != null || child.signalCode != null) {
202
+ resolve({ code: child.exitCode, signal: child.signalCode });
203
+ return;
204
+ }
205
+ const timer = setTimeout(() => {
206
+ child.removeListener('error', onError);
207
+ child.removeListener('exit', onExit);
208
+ reject(new Error(`Windows upgrade bootstrap did not exit within ${timeoutMs}ms`));
209
+ }, timeoutMs);
210
+ const onError = err => {
211
+ clearTimeout(timer);
212
+ child.removeListener('exit', onExit);
213
+ reject(err);
214
+ };
215
+ const onExit = (code, signal) => {
216
+ clearTimeout(timer);
217
+ child.removeListener('error', onError);
218
+ resolve({ code, signal });
219
+ };
220
+ child.once('error', onError);
221
+ child.once('exit', onExit);
222
+ });
223
+ }
224
+
225
+ function readHandoff(handoffPath, runId) {
226
+ try {
227
+ const handoff = JSON.parse(readFileSync(handoffPath, 'utf8'));
228
+ if (handoff?.runId !== runId) return null;
229
+ if (!Number.isInteger(handoff.runnerPid) || handoff.runnerPid <= 0) return null;
230
+ return handoff;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ function isProcessRunning(pid, probe = process.kill) {
237
+ try {
238
+ probe(pid, 0);
239
+ return true;
240
+ } catch (err) {
241
+ return err?.code === 'EPERM';
242
+ }
243
+ }
244
+
120
245
  async function waitForUpgradeHandoff({
121
246
  handoffPath,
247
+ runId,
122
248
  child,
123
249
  fileExists,
124
250
  sleep,
125
251
  timeoutMs,
126
252
  pollIntervalMs,
127
253
  getChildError,
254
+ processRunning,
128
255
  }) {
129
256
  const deadline = Date.now() + timeoutMs;
130
- let handoffSeen = false;
131
257
  while (Date.now() < deadline) {
132
258
  const childError = getChildError();
133
259
  if (childError) throw childError;
134
- if (child.exitCode != null || child.signalCode != null) {
135
- const status = child.exitCode != null ? `code ${child.exitCode}` : `signal ${child.signalCode}`;
136
- throw new Error(`Windows upgrade launcher exited before handoff (${status})`);
137
- }
138
260
 
139
- // Require the marker on two consecutive polls. A batch file that writes the
140
- // marker and immediately exits must not be allowed to tear down PM2.
141
- if (fileExists(handoffPath)) {
142
- if (handoffSeen) return;
143
- handoffSeen = true;
144
- } else {
145
- handoffSeen = false;
261
+ const handoff = fileExists(handoffPath) ? readHandoff(handoffPath, runId) : null;
262
+ if (handoff && processRunning(handoff.runnerPid)) return handoff;
263
+ // A clean bootstrap exit only confirms the detached spawn. The runner may
264
+ // still be starting and must get the rest of the handoff timeout to reply.
265
+ if (child.signalCode != null || (child.exitCode != null && child.exitCode !== 0)) {
266
+ const status = child.signalCode != null ? `signal ${child.signalCode}` : `code ${child.exitCode}`;
267
+ throw new Error(`Windows upgrade bootstrap exited before handoff (${status})`);
146
268
  }
147
269
  await sleep(pollIntervalMs);
148
270
  }
149
- throw new Error(`Windows upgrade launcher did not confirm handoff within ${timeoutMs}ms`);
271
+ throw new Error(`Windows upgrade runner did not confirm handoff within ${timeoutMs}ms`);
150
272
  }
151
273
 
152
274
  /**
153
- * Launch the detached Windows updater and confirm that its handoff marker
154
- * remains present before the caller stops PM2 or exits.
275
+ * Launch a short-lived bootstrap, verify the updater PID, wait for the bootstrap
276
+ * to exit, and verify the updater again. Only then may the caller exit or let
277
+ * the updater remove the PM2 app.
155
278
  */
156
279
  export async function launchWindowsUpgradeScript({
280
+ runId,
157
281
  nodePath,
282
+ bootstrapPath,
158
283
  runnerPath,
159
284
  payloadPath,
160
285
  logPath,
161
286
  handoffPath,
287
+ authorizePath,
288
+ cancelPath,
289
+ lockPath,
162
290
  spawnProcess,
163
291
  fileExists = existsSync,
164
292
  removeFile = path => rmSync(path, { force: true }),
293
+ writeCancel = (path, id) => writeFileSync(path, JSON.stringify({ runId: id, cancelledAt: Date.now() })),
294
+ writeAuthorize = (path, id) => writeFileSync(path, JSON.stringify({ runId: id, authorizedAt: Date.now() })),
165
295
  sleep = delay,
296
+ processRunning = isProcessRunning,
166
297
  timeoutMs = 5000,
167
298
  pollIntervalMs = 50,
168
- onHandoff,
169
299
  }) {
300
+ if (!runId) throw new TypeError('runId is required');
170
301
  if (typeof spawnProcess !== 'function') throw new TypeError('spawnProcess is required');
171
302
  if (!handoffPath) throw new TypeError('handoffPath is required');
303
+ if (!authorizePath) throw new TypeError('authorizePath is required');
304
+ if (!cancelPath) throw new TypeError('cancelPath is required');
305
+ if (!lockPath) throw new TypeError('lockPath is required');
172
306
 
173
- const invocation = buildWindowsUpgradeInvocation({ nodePath, runnerPath, payloadPath, logPath });
307
+ const invocation = buildWindowsUpgradeInvocation({ nodePath, bootstrapPath, runnerPath, payloadPath, logPath });
174
308
  let child;
175
309
  try {
176
310
  child = spawnProcess(invocation.command, invocation.args, invocation.options);
177
311
  } catch (err) {
178
- throw new Error(`Windows upgrade launcher failed: ${err.message}`, { cause: err });
312
+ releaseWindowsUpgradeLock(lockPath, runId);
313
+ throw new Error(`Windows upgrade bootstrap failed: ${err.message}`, { cause: err });
179
314
  }
180
315
 
181
316
  let childError = null;
317
+ let bootstrapSpawned = false;
182
318
  const onChildError = err => { childError = err; };
183
319
  child.on('error', onChildError);
184
320
  try {
185
321
  await waitForSpawn(child);
186
- await waitForUpgradeHandoff({
322
+ bootstrapSpawned = true;
323
+ const handoff = await waitForUpgradeHandoff({
187
324
  handoffPath,
325
+ runId,
188
326
  child,
189
327
  fileExists,
190
328
  sleep,
191
329
  timeoutMs,
192
330
  pollIntervalMs,
193
331
  getChildError: () => childError,
332
+ processRunning,
194
333
  });
195
- await onHandoff?.();
334
+ const status = await waitForExit(child, timeoutMs);
335
+ if (status.code !== 0) {
336
+ const detail = status.signal ? `signal ${status.signal}` : `code ${status.code}`;
337
+ throw new Error(`Windows upgrade bootstrap exited with ${detail}`);
338
+ }
339
+ if (!processRunning(handoff.runnerPid)) {
340
+ throw new Error('Windows upgrade runner did not survive bootstrap exit');
341
+ }
342
+ writeAuthorize(authorizePath, runId);
196
343
  } catch (err) {
344
+ try { writeCancel(cancelPath, runId); } catch {}
197
345
  try { child.kill(); } catch {}
198
346
  try { removeFile(handoffPath); } catch {}
347
+ if (!bootstrapSpawned) releaseWindowsUpgradeLock(lockPath, runId);
199
348
  if (childError === err) {
200
- throw new Error(`Windows upgrade launcher failed: ${err.message}`, { cause: err });
349
+ throw new Error(`Windows upgrade bootstrap failed: ${err.message}`, { cause: err });
201
350
  }
202
351
  throw err;
203
352
  } finally {
204
353
  child.removeListener('error', onChildError);
205
354
  }
206
355
 
207
- child.unref();
208
356
  return nodePath;
209
357
  }
210
358
 
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { appendFileSync } from 'node:fs';
4
+ import { spawn } from 'node:child_process';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ function appendLog(logPath, message) {
8
+ if (!logPath) return;
9
+ try {
10
+ appendFileSync(logPath, `[Upgrade] ${message}\n`);
11
+ } catch {}
12
+ }
13
+
14
+ /**
15
+ * Spawn the real updater from a short-lived intermediate process. Once this
16
+ * bootstrap exits, the updater is no longer in the live process tree rooted at
17
+ * the PM2-managed Agent, so PM2's Windows tree kill cannot terminate it.
18
+ */
19
+ export function spawnWindowsUpgradeRunner({
20
+ nodePath,
21
+ runnerPath,
22
+ payloadPath,
23
+ logPath,
24
+ }, spawnProcess = spawn) {
25
+ return new Promise((resolve, reject) => {
26
+ let child;
27
+ try {
28
+ child = spawnProcess(nodePath, [runnerPath, payloadPath], {
29
+ detached: true,
30
+ stdio: 'ignore',
31
+ windowsHide: true,
32
+ env: { ...process.env, YEAFT_UPGRADE_LOG: logPath },
33
+ });
34
+ } catch (err) {
35
+ reject(err);
36
+ return;
37
+ }
38
+
39
+ const onError = err => {
40
+ child.removeListener('spawn', onSpawn);
41
+ reject(err);
42
+ };
43
+ const onSpawn = () => {
44
+ child.removeListener('error', onError);
45
+ child.unref();
46
+ resolve(child.pid);
47
+ };
48
+ child.once('error', onError);
49
+ child.once('spawn', onSpawn);
50
+ });
51
+ }
52
+
53
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
54
+ const [runnerPath, payloadPath] = process.argv.slice(2);
55
+ const logPath = process.env.YEAFT_UPGRADE_LOG;
56
+ try {
57
+ if (!runnerPath || !payloadPath) throw new Error('runner path and payload path are required');
58
+ const runnerPid = await spawnWindowsUpgradeRunner({
59
+ nodePath: process.execPath,
60
+ runnerPath,
61
+ payloadPath,
62
+ logPath,
63
+ });
64
+ appendLog(logPath, `Bootstrap launched updater PID ${runnerPid}`);
65
+ } catch (err) {
66
+ appendLog(logPath, `Bootstrap failed: ${err?.stack || err}`);
67
+ process.exitCode = 1;
68
+ }
69
+ }
@@ -7,12 +7,15 @@ import { pathToFileURL } from 'node:url';
7
7
  import { setTimeout as delay } from 'node:timers/promises';
8
8
  import {
9
9
  buildUpgradeInstallArgs,
10
+ releaseWindowsUpgradeLock,
10
11
  resolveWindowsNpmCliPath,
11
12
  } from './upgrade-command.js';
12
13
 
13
14
  const PID_POLL_INTERVAL_MS = 100;
14
15
  const PID_WAIT_TIMEOUT_MS = 30_000;
16
+ const HANDOFF_AUTH_TIMEOUT_MS = 15_000;
15
17
  const FILE_LOCK_RETRY_MS = [0, 250, 750, 1_500];
18
+ const PM2_RETRY_MS = [0, 250, 750];
16
19
 
17
20
  function appendLog(logPath, message) {
18
21
  try {
@@ -107,79 +110,203 @@ export async function installWindowsUpgrade({
107
110
  return { exitCode: 1, attempts: FILE_LOCK_RETRY_MS.length, command, args };
108
111
  }
109
112
 
110
- export async function startPm2Service({ nodePath, pm2CliPath, ecosystemPath, logPath, run = runProcess }) {
111
- if (!pm2CliPath || !ecosystemPath) return true;
112
- appendLog(logPath, 'Re-registering Agent via PM2');
113
- const startCode = await run(nodePath, [pm2CliPath, 'start', ecosystemPath], {
113
+ export async function stopPm2Service({ nodePath, pm2CliPath, pm2AppName, logPath, run = runProcess }) {
114
+ if (!pm2CliPath || !pm2AppName) return true;
115
+ appendLog(logPath, `Removing PM2 app ${pm2AppName} before install`);
116
+ const deleteCode = await run(nodePath, [pm2CliPath, 'delete', pm2AppName], {
114
117
  env: process.env,
115
118
  windowsHide: true,
116
119
  stdio: 'ignore',
117
120
  });
118
- if (startCode !== 0) return false;
119
- const saveCode = await run(nodePath, [pm2CliPath, 'save'], {
120
- env: process.env,
121
- windowsHide: true,
122
- stdio: 'ignore',
121
+ return deleteCode === 0;
122
+ }
123
+
124
+ async function runPm2WithRetry({ nodePath, pm2CliPath, args, logPath, label, run, sleep }) {
125
+ for (let attempt = 0; attempt < PM2_RETRY_MS.length; attempt++) {
126
+ const retryDelayMs = PM2_RETRY_MS[attempt];
127
+ if (retryDelayMs) {
128
+ appendLog(logPath, `Retrying PM2 ${label} after ${retryDelayMs}ms`);
129
+ await sleep(retryDelayMs);
130
+ }
131
+ try {
132
+ const code = await run(nodePath, [pm2CliPath, ...args], {
133
+ env: process.env,
134
+ windowsHide: true,
135
+ stdio: 'ignore',
136
+ });
137
+ if (code === 0) return true;
138
+ appendLog(logPath, `PM2 ${label} failed with exit code ${code} (attempt ${attempt + 1})`);
139
+ } catch (err) {
140
+ appendLog(logPath, `PM2 ${label} failed to run (attempt ${attempt + 1}): ${err?.message || err}`);
141
+ }
142
+ }
143
+ return false;
144
+ }
145
+
146
+ export async function startPm2Service({
147
+ nodePath,
148
+ pm2CliPath,
149
+ ecosystemPath,
150
+ logPath,
151
+ run = runProcess,
152
+ sleep = delay,
153
+ }) {
154
+ if (!pm2CliPath || !ecosystemPath) return true;
155
+ appendLog(logPath, 'Re-registering Agent via PM2');
156
+ const started = await runPm2WithRetry({
157
+ nodePath,
158
+ pm2CliPath,
159
+ args: ['start', ecosystemPath],
160
+ logPath,
161
+ label: 'start',
162
+ run,
163
+ sleep,
164
+ });
165
+ if (!started) return false;
166
+ return runPm2WithRetry({
167
+ nodePath,
168
+ pm2CliPath,
169
+ args: ['save'],
170
+ logPath,
171
+ label: 'save',
172
+ run,
173
+ sleep,
123
174
  });
124
- return saveCode === 0;
175
+ }
176
+
177
+ function readRunMarker(path, runId) {
178
+ try {
179
+ return JSON.parse(readFileSync(path, 'utf8'))?.runId === runId;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ async function waitForHandoffAuthorization({ authorizePath, cancelPath, runId, sleep = delay }) {
186
+ const deadline = Date.now() + HANDOFF_AUTH_TIMEOUT_MS;
187
+ while (Date.now() < deadline) {
188
+ if (readRunMarker(cancelPath, runId)) return false;
189
+ if (readRunMarker(authorizePath, runId)) return true;
190
+ await sleep(PID_POLL_INTERVAL_MS);
191
+ }
192
+ return false;
193
+ }
194
+
195
+ function removeTransientFiles(handoffPath, authorizePath, payloadPath, cancelPath) {
196
+ for (const path of [handoffPath, authorizePath, payloadPath, cancelPath]) {
197
+ try { rmSync(path, { force: true }); } catch {}
198
+ }
125
199
  }
126
200
 
127
201
  /**
128
- * Run after the original Agent exits. No shell pipeline is involved: Windows
129
- * PID polling is handled by Node and npm/PM2 run through their JS entry points.
202
+ * Wait for the original Agent to exit, remove its PM2 registration, install the
203
+ * exact package version, and restore the selected PM2 instance. Every process
204
+ * invocation is shell-free.
130
205
  */
131
206
  export async function runWindowsUpgrade(options, dependencies = {}) {
132
207
  const {
208
+ runId,
209
+ lockPath,
133
210
  parentPid,
134
211
  packageSpec,
135
212
  globalInstall,
136
213
  installDir,
137
214
  logPath,
138
215
  handoffPath,
216
+ authorizePath,
217
+ cancelPath,
218
+ bootstrapPath,
139
219
  runnerPath,
140
220
  commandPath,
141
221
  payloadPath,
142
222
  nodePath,
143
223
  npmCliPath,
144
224
  pm2CliPath,
225
+ pm2AppName,
145
226
  ecosystemPath,
146
227
  } = options;
147
228
  const wait = dependencies.waitForProcessExit || waitForProcessExit;
148
229
  const install = dependencies.installWindowsUpgrade || installWindowsUpgrade;
230
+ const stopService = dependencies.stopPm2Service || stopPm2Service;
149
231
  const startService = dependencies.startPm2Service || startPm2Service;
232
+ const authorizeHandoff = dependencies.waitForHandoffAuthorization || waitForHandoffAuthorization;
233
+ const releaseLock = dependencies.releaseWindowsUpgradeLock || releaseWindowsUpgradeLock;
234
+ const cleanupPaths = [bootstrapPath, runnerPath, commandPath];
235
+ const cancelled = () => cancelPath && readRunMarker(cancelPath, runId);
150
236
 
151
- mkdirSync(dirname(handoffPath), { recursive: true });
152
- writeFileSync(handoffPath, 'started');
153
- appendLog(logPath, `Started at ${new Date().toISOString()}`);
154
- appendLog(logPath, `Waiting for PID ${parentPid} to exit`);
155
- const exited = await wait(parentPid);
156
- if (!exited) appendLog(logPath, `Timed out waiting for PID ${parentPid}; continuing with bounded npm retries`);
157
- else appendLog(logPath, 'Original process exited');
237
+ if (!runId) throw new TypeError('runId is required');
238
+ if (!lockPath) throw new TypeError('lockPath is required');
158
239
 
159
- let result;
160
240
  try {
161
- result = await install({ nodePath, npmCliPath, packageSpec, globalInstall, installDir, logPath });
162
- } catch (err) {
163
- appendLog(logPath, `npm install failed to start: ${err?.message || err}`);
164
- result = { exitCode: 1, attempts: 0 };
165
- }
166
- appendLog(logPath, `npm install ${result.exitCode === 0 ? 'succeeded' : `failed with exit code ${result.exitCode}`} after ${result.attempts} attempt(s)`);
241
+ mkdirSync(dirname(handoffPath), { recursive: true });
242
+ if (cancelled()) {
243
+ appendLog(logPath, 'Upgrade was cancelled before handoff; refusing to modify the installation');
244
+ return { exitCode: 1, restarted: false, cleanupPaths };
245
+ }
246
+ writeFileSync(handoffPath, JSON.stringify({ runId, runnerPid: process.pid, startedAt: Date.now() }));
247
+ appendLog(logPath, `Started run ${runId} at ${new Date().toISOString()}`);
248
+ const authorized = await authorizeHandoff({ authorizePath, cancelPath, runId });
249
+ if (!authorized) {
250
+ appendLog(logPath, 'Upgrade handoff was not authorized; refusing to modify the installation');
251
+ return { exitCode: 1, restarted: false, cleanupPaths };
252
+ }
253
+ appendLog(logPath, `Waiting for PID ${parentPid} to exit`);
254
+ const exited = await wait(parentPid);
255
+ if (!exited) {
256
+ appendLog(logPath, `Timed out waiting for PID ${parentPid}; refusing to modify a live installation`);
257
+ return { exitCode: 1, restarted: false, cleanupPaths };
258
+ }
259
+ appendLog(logPath, 'Original process exited');
260
+ if (cancelled()) {
261
+ appendLog(logPath, 'Upgrade was cancelled during handoff; refusing to modify the installation');
262
+ return { exitCode: 1, restarted: false, cleanupPaths };
263
+ }
167
264
 
168
- let restarted = false;
169
- try {
170
- restarted = await startService({ nodePath, pm2CliPath, ecosystemPath, logPath });
171
- } catch (err) {
172
- appendLog(logPath, `WARNING: PM2 service restart failed: ${err?.message || err}`);
173
- }
174
- if (!restarted) appendLog(logPath, 'WARNING: PM2 service was not restarted');
175
- appendLog(logPath, `Finished at ${new Date().toISOString()}`);
265
+ let stopped = false;
266
+ try {
267
+ stopped = await stopService({ nodePath, pm2CliPath, pm2AppName, logPath });
268
+ } catch (err) {
269
+ appendLog(logPath, `PM2 app removal failed: ${err?.message || err}`);
270
+ }
271
+ if (!stopped) {
272
+ appendLog(logPath, `PM2 app ${pm2AppName} could not be removed; refusing to install`);
273
+ let restored = false;
274
+ try {
275
+ restored = await startService({ nodePath, pm2CliPath, ecosystemPath, logPath });
276
+ } catch (err) {
277
+ appendLog(logPath, `WARNING: PM2 recovery failed: ${err?.message || err}`);
278
+ }
279
+ if (!restored) appendLog(logPath, 'WARNING: PM2 service was not restored after removal failure');
280
+ return { exitCode: 1, restarted: restored, cleanupPaths };
281
+ }
176
282
 
177
- // Do not delete the running script. Windows keeps it executable until exit,
178
- // but deferred cleanup on the next upgrade is more reliable than self-delete.
179
- for (const path of [handoffPath, payloadPath]) {
180
- try { rmSync(path, { force: true }); } catch {}
283
+ let result;
284
+ try {
285
+ result = await install({ nodePath, npmCliPath, packageSpec, globalInstall, installDir, logPath });
286
+ } catch (err) {
287
+ appendLog(logPath, `npm install failed to start: ${err?.message || err}`);
288
+ result = { exitCode: 1, attempts: 0 };
289
+ }
290
+ appendLog(logPath, `npm install ${result.exitCode === 0 ? 'succeeded' : `failed with exit code ${result.exitCode}`} after ${result.attempts} attempt(s)`);
291
+
292
+ let restarted = false;
293
+ try {
294
+ restarted = await startService({ nodePath, pm2CliPath, ecosystemPath, logPath });
295
+ } catch (err) {
296
+ appendLog(logPath, `WARNING: PM2 service restart failed: ${err?.message || err}`);
297
+ }
298
+ if (!restarted) appendLog(logPath, 'WARNING: PM2 service was not restarted');
299
+ const exitCode = result.exitCode === 0 && restarted ? 0 : 1;
300
+ appendLog(logPath, `Finished at ${new Date().toISOString()} with exit code ${exitCode}`);
301
+ return { exitCode, restarted, cleanupPaths };
302
+ } finally {
303
+ removeTransientFiles(handoffPath, authorizePath, payloadPath, cancelPath);
304
+ if (!releaseLock(lockPath, runId)) {
305
+ const message = `Upgrade lock was not released for run ${runId}`;
306
+ appendLog(logPath, message);
307
+ throw new Error(message);
308
+ }
181
309
  }
182
- return { exitCode: result.exitCode, restarted, cleanupPaths: [runnerPath, commandPath] };
183
310
  }
184
311
 
185
312
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {