onbuzz 5.4.4 → 5.4.8

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": "onbuzz",
3
- "version": "5.4.4",
3
+ "version": "5.4.8",
4
4
  "description": "Loxia OnBuzz - Your AI Fleet",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -33,9 +33,9 @@
33
33
  "electron:build:win": "npm run build:web-ui && electron-builder --win",
34
34
  "electron:build:mac": "npm run build:web-ui && electron-builder --mac",
35
35
  "electron:build:linux": "npm run build:web-ui && electron-builder --linux",
36
- "electron:build:onbuzz:win": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --win -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.win.icon=electron/icon-onbuzz.png",
37
- "electron:build:onbuzz:mac": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --mac -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.mac.icon=electron/icon-onbuzz.png",
38
- "electron:build:onbuzz:linux": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --linux -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.linux.icon=electron/icon-onbuzz.png"
36
+ "electron:build:onbuzz:win": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --win -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.win.icon=electron/icon-onbuzz.png -c.nsis.artifactName=Loxia-OnBuzz-Setup-${version}.${ext}",
37
+ "electron:build:onbuzz:mac": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --mac -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.mac.icon=electron/icon-onbuzz.png -c.mac.artifactName=Loxia-OnBuzz-${version}-${arch}.${ext}",
38
+ "electron:build:onbuzz:linux": "cross-env VITE_BRAND=onbuzz npm run build:web-ui && cross-env LOXIA_BRAND=onbuzz electron-builder --linux -c.productName=\"Loxia OnBuzz\" -c.appId=com.loxia.onbuzz -c.executableName=loxia-onbuzz -c.linux.icon=electron/icon-onbuzz.png -c.linux.artifactName=Loxia-OnBuzz-${version}.${ext}"
39
39
  },
40
40
  "keywords": [
41
41
  "ai",
@@ -158,11 +158,16 @@
158
158
  },
159
159
  "mac": {
160
160
  "target": "dmg",
161
- "icon": "electron/icon.png"
161
+ "icon": "electron/icon.png",
162
+ "artifactName": "Loxia-Autopilot-${version}-${arch}.${ext}"
162
163
  },
163
164
  "linux": {
164
165
  "target": "AppImage",
165
- "icon": "electron/icon.png"
166
+ "icon": "electron/icon.png",
167
+ "artifactName": "Loxia-Autopilot-${version}.${ext}"
168
+ },
169
+ "nsis": {
170
+ "artifactName": "Loxia-Autopilot-Setup-${version}.${ext}"
166
171
  }
167
172
  },
168
173
  "pkg": {
@@ -204,4 +209,4 @@
204
209
  "glob",
205
210
  "rimraf"
206
211
  ]
207
- }
212
+ }
@@ -5,10 +5,25 @@
5
5
  * Bumps version across ALL files that track it, commits, tags, and pushes.
6
6
  *
7
7
  * Usage:
8
- * node scripts/bump-version.js patch → 3.6.0 → 3.6.1
9
- * node scripts/bump-version.js minor → 3.6.0 → 3.7.0
10
- * node scripts/bump-version.js major → 3.6.0 → 4.0.0
11
- * node scripts/bump-version.js 3.8.0 → explicit version
8
+ * node scripts/bump-version.js patch → 3.6.0 → 3.6.1
9
+ * node scripts/bump-version.js minor → 3.6.0 → 3.7.0
10
+ * node scripts/bump-version.js major → 3.6.0 → 4.0.0
11
+ * node scripts/bump-version.js 3.8.0 → explicit version
12
+ * node scripts/bump-version.js --status → print the version truth table
13
+ * (package files, latest git tag,
14
+ * npm latest) and exit
15
+ * node scripts/bump-version.js <v> --dry-run
16
+ * → run every guard + print the plan,
17
+ * write/commit/push NOTHING
18
+ *
19
+ * Guards (all run BEFORE any file is written):
20
+ * - explicit versions must be exact semver (x.y.z or x.y.z-pre)
21
+ * - the target git tag must NOT already exist → hard fail
22
+ * - the target version must NOT be on npm → hard fail
23
+ * These exist because a release run that half-succeeds (tag pushed, npm
24
+ * publish failed) leaves the ecosystem desynced — package.json said 5.4.7
25
+ * while the operator believed 5.4.5, and npm was missing 5.4.5/5.4.6
26
+ * entirely. Fail EARLY, before anything is mutated.
12
27
  */
13
28
 
14
29
  import fs from 'fs';
@@ -18,6 +33,7 @@ import { fileURLToPath } from 'url';
18
33
 
19
34
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
35
  const ROOT = path.resolve(__dirname, '..');
36
+ const NPM_PKG = '@loxia-labs/loxia-autopilot-one';
21
37
 
22
38
  // ─── All files that contain a version to sync ──────────────────────────────
23
39
 
@@ -46,6 +62,8 @@ function writeVersion(relPath, newVersion) {
46
62
 
47
63
  // ─── Bump logic ────────────────────────────────────────────────────────────
48
64
 
65
+ const EXPLICIT_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
66
+
49
67
  function bumpVersion(current, type) {
50
68
  const [major, minor, patch] = current.split('.').map(Number);
51
69
  switch (type) {
@@ -53,24 +71,131 @@ function bumpVersion(current, type) {
53
71
  case 'minor': return `${major}.${minor + 1}.0`;
54
72
  case 'patch': return `${major}.${minor}.${patch + 1}`;
55
73
  default:
56
- // Explicit version string — validate format
57
- if (/^\d+\.\d+\.\d+/.test(type)) return type;
74
+ // Explicit version string — must be EXACT semver, no trailing junk.
75
+ if (EXPLICIT_RE.test(type)) return type;
58
76
  throw new Error(`Invalid bump type: "${type}". Use: patch, minor, major, or explicit version (e.g. 3.7.0)`);
59
77
  }
60
78
  }
61
79
 
80
+ // ─── Ecosystem truth (git tags + npm registry) ─────────────────────────────
81
+
82
+ function gitTagExists(version) {
83
+ try {
84
+ const out = execSync(`git tag -l v${version}`, { cwd: ROOT, stdio: 'pipe' }).toString().trim();
85
+ return out === `v${version}`;
86
+ } catch { return false; }
87
+ }
88
+
89
+ function latestGitTag() {
90
+ try {
91
+ return execSync('git tag -l "v*" --sort=-v:refname', { cwd: ROOT, stdio: 'pipe' })
92
+ .toString().split('\n')[0].trim() || '(none)';
93
+ } catch { return '(unavailable)'; }
94
+ }
95
+
96
+ /** @returns {'exists'|'missing'|'unknown'} */
97
+ function npmVersionState(version) {
98
+ try {
99
+ const out = execSync(`npm view ${NPM_PKG}@${version} version`, { cwd: ROOT, stdio: 'pipe' })
100
+ .toString().trim();
101
+ return out === version ? 'exists' : 'missing';
102
+ } catch (err) {
103
+ // npm view exits 1 with E404 when the version isn't published — that's
104
+ // the GOOD case. Anything else (network down) is genuinely unknown.
105
+ const msg = String(err?.stderr || err?.message || '');
106
+ return msg.includes('E404') ? 'missing' : 'unknown';
107
+ }
108
+ }
109
+
110
+ function npmLatest() {
111
+ try {
112
+ return execSync(`npm view ${NPM_PKG} version`, { cwd: ROOT, stdio: 'pipe' }).toString().trim();
113
+ } catch { return '(unavailable)'; }
114
+ }
115
+
116
+ // ─── Status panel ──────────────────────────────────────────────────────────
117
+
118
+ function printStatus() {
119
+ const pkgV = readVersion('package.json');
120
+ let tplV = '(missing)';
121
+ try { tplV = readVersion('package.autopilot.json'); } catch { /* absent */ }
122
+ const tag = latestGitTag();
123
+ const npmV = npmLatest();
124
+
125
+ const sync = pkgV === tplV ? 'in sync' : '⚠ OUT OF SYNC';
126
+ console.log('');
127
+ console.log(' ── Version truth table ─────────────────────────');
128
+ console.log(` package.json : ${pkgV}`);
129
+ console.log(` package.autopilot.json : ${tplV} (${sync})`);
130
+ console.log(` latest git tag : ${tag}`);
131
+ console.log(` npm latest (${NPM_PKG.split('/')[1]}) : ${npmV}`);
132
+ console.log(` next patch would be : ${bumpVersion(pkgV, 'patch')}`);
133
+ if (npmV !== '(unavailable)' && npmV !== pkgV) {
134
+ console.log(` ⚠ package.json (${pkgV}) != npm latest (${npmV}) — pick your target deliberately.`);
135
+ }
136
+ console.log(' ────────────────────────────────────────────────');
137
+ console.log('');
138
+ }
139
+
62
140
  // ─── Main ──────────────────────────────────────────────────────────────────
63
141
 
64
- const arg = process.argv[2];
142
+ const args = process.argv.slice(2);
143
+ const dryRun = args.includes('--dry-run');
144
+ const positional = args.filter((a) => !a.startsWith('--'));
145
+ const arg = positional[0];
146
+
147
+ if (args.includes('--status')) {
148
+ printStatus();
149
+ process.exit(0);
150
+ }
151
+
65
152
  if (!arg) {
66
- console.log('Usage: node scripts/bump-version.js <patch|minor|major|x.y.z>');
153
+ console.log('Usage: node scripts/bump-version.js <patch|minor|major|x.y.z> [--dry-run] | --status');
67
154
  process.exit(1);
68
155
  }
69
156
 
70
157
  const currentVersion = readVersion('package.json');
71
- const newVersion = bumpVersion(currentVersion, arg);
158
+ let newVersion;
159
+ try {
160
+ newVersion = bumpVersion(currentVersion, arg);
161
+ } catch (err) {
162
+ console.error(`\n ❌ ${err.message}\n`);
163
+ process.exit(1);
164
+ }
72
165
 
73
- console.log(`\n Version bump: ${currentVersion} ${newVersion}\n`);
166
+ // ── Guards: fail BEFORE mutating anything ──────────────────────────────────
167
+
168
+ if (gitTagExists(newVersion)) {
169
+ console.error(`\n ❌ Tag v${newVersion} already exists — this version was already cut.`);
170
+ console.error(` Pick a different version (check with: node scripts/bump-version.js --status)\n`);
171
+ process.exit(1);
172
+ }
173
+
174
+ const npmState = npmVersionState(newVersion);
175
+ if (npmState === 'exists') {
176
+ console.error(`\n ❌ ${NPM_PKG}@${newVersion} is already published on npm — npm will reject a republish.`);
177
+ console.error(` Pick a different version (check with: node scripts/bump-version.js --status)\n`);
178
+ process.exit(1);
179
+ }
180
+ if (npmState === 'unknown') {
181
+ console.log(` ⚠ Could not verify npm registry (offline?) — continuing, but publish may fail if ${newVersion} exists.`);
182
+ }
183
+
184
+ console.log(`\n Version bump: ${currentVersion} → ${newVersion}${dryRun ? ' [DRY RUN — nothing will be written]' : ''}\n`);
185
+
186
+ if (dryRun) {
187
+ for (const { file, independent } of VERSION_FILES) {
188
+ const fullPath = path.join(ROOT, file);
189
+ if (!fs.existsSync(fullPath)) { console.log(` ⚠ Would skip (not found): ${file}`); continue; }
190
+ const target = independent && EXPLICIT_RE.test(arg)
191
+ ? bumpVersion(readVersion(file), 'patch')
192
+ : independent ? bumpVersion(readVersion(file), arg) : newVersion;
193
+ console.log(` → would set ${file}: ${readVersion(file)} → ${target}`);
194
+ }
195
+ console.log(` → would commit "v${newVersion}", tag v${newVersion}, push origin main --tags`);
196
+ console.log('\n ✅ All guards passed (dry run).\n');
197
+ process.exit(0);
198
+ }
74
199
 
75
200
  // Update all files
76
201
  for (const { file, independent } of VERSION_FILES) {
@@ -82,11 +207,11 @@ for (const { file, independent } of VERSION_FILES) {
82
207
 
83
208
  let targetVersion = newVersion;
84
209
 
85
- // web-ui has its own version scheme bump its patch independently
210
+ // web-ui has its own version scheme. For patch/minor/major it takes the
211
+ // same bump TYPE; for an EXPLICIT main version it takes a patch bump —
212
+ // an explicit "5.4.8" must not stomp web-ui's independent numbering.
86
213
  if (independent) {
87
- const webVersion = readVersion(file);
88
- const webNew = bumpVersion(webVersion, arg.match(/^\d/) ? arg : arg); // same bump type
89
- targetVersion = webNew;
214
+ targetVersion = bumpVersion(readVersion(file), EXPLICIT_RE.test(arg) ? 'patch' : arg);
90
215
  }
91
216
 
92
217
  const old = writeVersion(file, targetVersion);
@@ -103,12 +228,9 @@ console.log(` ✓ Staged ${filesToStage.length} files`);
103
228
  execSync(`git commit -m "v${newVersion}"`, { cwd: ROOT, stdio: 'pipe' });
104
229
  console.log(` ✓ Committed: v${newVersion}`);
105
230
 
106
- try {
107
- execSync(`git tag v${newVersion}`, { cwd: ROOT, stdio: 'pipe' });
108
- console.log(` ✓ Tagged: v${newVersion}`);
109
- } catch {
110
- console.log(` ⚠ Tag v${newVersion} already exists, skipping`);
111
- }
231
+ // Guard ran pre-flight, so a collision here means a race — surface it loudly.
232
+ execSync(`git tag v${newVersion}`, { cwd: ROOT, stdio: 'pipe' });
233
+ console.log(` ✓ Tagged: v${newVersion}`);
112
234
 
113
235
  execSync(`git push origin main --tags`, { cwd: ROOT, stdio: 'pipe' });
114
236
  console.log(` ✓ Pushed to origin/main with tags`);
@@ -77,6 +77,18 @@ const SOFT_REQUIRED = [
77
77
  breaks: 'Electron in-app auto-update dialog + background download',
78
78
  fixHint: 'npm install --save-optional electron-updater@^6.8.3',
79
79
  },
80
+ {
81
+ name: '@nut-tree-fork/nut-js',
82
+ // Used by src/tools/desktop/osController.js (lazy dynamic import).
83
+ // ~30MB native module — MUST stay in optionalDependencies so a failed
84
+ // native build (Wayland, minimal containers) degrades to the
85
+ // NATIVE_UNAVAILABLE fallback instead of breaking the whole install.
86
+ // This entry exists because the dep was MISSING from package.json for
87
+ // months (field report: agents told users to `npm install` it by
88
+ // hand) — never let an audit-fix quietly reintroduce that gap.
89
+ breaks: 'DesktopTool (OS-level mouse/keyboard/screen control) — agents instruct users to install it manually',
90
+ fixHint: 'npm install --save-optional @nut-tree-fork/nut-js@^4.2.6',
91
+ },
80
92
  {
81
93
  name: 'jsdom',
82
94
  // Required by widget runtime/acceptance tests (84 tests across
@@ -106,8 +118,15 @@ const SOFT_REQUIRED = [
106
118
  },
107
119
  ];
108
120
 
121
+ // --simulate-missing=<name,name>: treat the named deps as missing even when
122
+ // they resolve. Dry-run tool — lets an operator (or a test) verify the banner
123
+ // and the emitted restore command without actually uninstalling anything.
124
+ const simArg = process.argv.find((a) => a.startsWith('--simulate-missing='));
125
+ const simulated = new Set(simArg ? simArg.split('=')[1].split(',').filter(Boolean) : []);
126
+
109
127
  const missing = [];
110
128
  for (const dep of SOFT_REQUIRED) {
129
+ if (simulated.has(dep.name)) { missing.push(dep); continue; }
111
130
  try {
112
131
  require.resolve(dep.name, { paths: [repoRoot] });
113
132
  } catch {
@@ -120,14 +139,29 @@ if (missing.length === 0) process.exit(0);
120
139
  // --emit-install: print a single composite `npm install ...` command to
121
140
  // stdout (separate from the stderr banner). release.bat captures stdout
122
141
  // for the [r]estore path. Newline-terminated, no extra logging on stdout.
142
+ //
143
+ // Deps are GROUPED by the save flag in their fixHint: restoring an
144
+ // optionalDependency (electron-updater, nut-js) with a flat `--save` would
145
+ // silently relocate it into hard `dependencies` — turning "native build may
146
+ // fail, that's fine" into "native build failure breaks the whole install".
147
+ // Groups are chained with ` && ` (cmd.exe and sh both honor it).
123
148
  if (process.argv.includes('--emit-install')) {
124
- const args = missing.map((m) => {
149
+ const groups = { '--save': [], '--save-optional': [], '--save-dev': [] };
150
+ for (const m of missing) {
125
151
  // Pull the version pin out of the fixHint ("npm install pkg@^x.y.z # …").
126
152
  // Falls back to no-pin if the hint doesn't follow the convention.
127
153
  const match = m.fixHint.match(/(@?[\w/.-]+@[\^~]?[\w.-]+)/);
128
- return match ? match[1] : m.name;
129
- }).join(' ');
130
- process.stdout.write(`npm install --save ${args}\n`);
154
+ const spec = match ? match[1] : m.name;
155
+ const flag = m.fixHint.includes('--save-optional') ? '--save-optional'
156
+ : m.fixHint.includes('--save-dev') ? '--save-dev'
157
+ : '--save';
158
+ groups[flag].push(spec);
159
+ }
160
+ const cmd = Object.entries(groups)
161
+ .filter(([, specs]) => specs.length > 0)
162
+ .map(([flag, specs]) => `npm install ${flag} ${specs.join(' ')}`)
163
+ .join(' && ');
164
+ process.stdout.write(`${cmd}\n`);
131
165
  // Banner still went to stderr above.
132
166
  process.exit(1);
133
167
  }
@@ -87,19 +87,39 @@ describe('saveJSON — atomic write', () => {
87
87
  expect(mockUnlink).not.toHaveBeenCalled(); // ultimately succeeded — no cleanup
88
88
  });
89
89
 
90
- test('gives up after exhausting rename retries and cleans the temp', async () => {
90
+ // THE CRASH FIX. On Windows, rename over a file with an open read handle
91
+ // fails EPERM — and readers are constant (agent-list scans, UI polling).
92
+ // When retries exhaust, saveJSON must NOT reject: most persist calls are
93
+ // fire-and-forget, so the rejection would reach the global
94
+ // unhandledRejection handler, whose default branch process.exit(1)'s —
95
+ // that chain crashed the app at launch / during agent load. Instead it
96
+ // falls back to the pre-atomic DIRECT write (allowed alongside open read
97
+ // handles) and cleans the temp.
98
+ test('rename exhaustion under contention falls back to a direct write — NO rejection', async () => {
91
99
  const sm = makeSM();
92
100
  mockRename.mockRejectedValue(Object.assign(new Error('busy'), { code: 'EBUSY' }));
93
- await expect(sm.saveJSON('/mock/userdata/state/r2.json', {})).rejects.toThrow('busy');
94
- expect(mockRename).toHaveBeenCalledTimes(5);
101
+
102
+ await expect(sm.saveJSON('/mock/userdata/state/r2.json', { ok: 1 })).resolves.toBeUndefined();
103
+
104
+ expect(mockRename).toHaveBeenCalledTimes(8); // patient retry first
105
+ // Final write went DIRECTLY to the live path (the fallback)...
106
+ const lastWrite = mockWriteFile.mock.calls.at(-1);
107
+ expect(lastWrite[0]).toBe('/mock/userdata/state/r2.json');
108
+ expect(JSON.parse(lastWrite[1])).toEqual({ ok: 1 });
109
+ // ...and the orphaned temp was cleaned up.
95
110
  expect(mockUnlink).toHaveBeenCalledTimes(1);
96
- });
111
+ }, 20000); // real backoff timers (~5.5s) run in this test
97
112
 
98
- test('a non-transient rename error is NOT retried', async () => {
113
+ test('a non-transient rename error is NOT retried, NOT fallback-written, and still throws', async () => {
99
114
  const sm = makeSM();
100
115
  mockRename.mockRejectedValue(Object.assign(new Error('nospace'), { code: 'ENOSPC' }));
101
116
  await expect(sm.saveJSON('/mock/userdata/state/r3.json', {})).rejects.toThrow('nospace');
102
117
  expect(mockRename).toHaveBeenCalledTimes(1);
118
+ // No direct write to the live path — a real error must stay visible.
119
+ for (const [p] of mockWriteFile.mock.calls) {
120
+ expect(p).not.toBe('/mock/userdata/state/r3.json');
121
+ }
122
+ expect(mockUnlink).toHaveBeenCalledTimes(1); // temp cleaned
103
123
  });
104
124
  });
105
125
 
@@ -1278,18 +1278,46 @@ class StateManager {
1278
1278
  await fs.writeFile(tmpPath, contents, 'utf8');
1279
1279
  await this._renameWithRetry(tmpPath, filePath);
1280
1280
  } catch (err) {
1281
- // Best-effort cleanup of the temp file; never mask the original error.
1281
+ const transient = err && (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY');
1282
+ if (transient) {
1283
+ // SHARING-VIOLATION FALLBACK — this branch is load-bearing. On
1284
+ // Windows, rename over a file that ANY reader holds open fails with
1285
+ // EPERM, and readers are constant here (agent-list scans, snippet
1286
+ // enrichment, UI polling). When retries exhaust under sustained
1287
+ // contention, DO NOT throw: most persist calls are fire-and-forget,
1288
+ // so the rejection reaches process.on('unhandledRejection') — whose
1289
+ // default branch calls process.exit(1). That exact chain crashed the
1290
+ // app "sometimes at launch, sometimes while loading an agent".
1291
+ // Fall back to the pre-atomic direct write instead: Windows allows
1292
+ // in-place writes alongside open read handles, and our per-path
1293
+ // write chain already guarantees no same-file write interleaving.
1294
+ // Trade-off: this one write loses crash-atomicity — strictly no
1295
+ // worse than the original implementation, and only under contention.
1296
+ this.logger?.warn?.(
1297
+ `Atomic rename blocked by open handles — falling back to direct write: ${path.basename(filePath)}`,
1298
+ { filePath, code: err.code }
1299
+ );
1300
+ try {
1301
+ await fs.writeFile(filePath, contents, 'utf8');
1302
+ return;
1303
+ } finally {
1304
+ try { await fs.unlink(tmpPath); } catch { /* already gone */ }
1305
+ }
1306
+ }
1307
+ // Non-transient (ENOSPC, EROFS, …): clean up and surface the real error.
1282
1308
  try { await fs.unlink(tmpPath); } catch { /* already gone */ }
1283
1309
  throw err;
1284
1310
  }
1285
1311
  }
1286
1312
 
1287
1313
  /**
1288
- * fs.rename with a small retry for the transient Windows sharing-violation
1289
- * errors that occur when a replace races an open read handle.
1314
+ * fs.rename with retries for the transient Windows sharing-violation
1315
+ * errors that occur when a replace races an open read handle. Backoff is
1316
+ * patient (~9s worst case) because big conversation files (tens of MB)
1317
+ * can legitimately hold read handles for seconds during agent load.
1290
1318
  * @private
1291
1319
  */
1292
- async _renameWithRetry(from, to, attempts = 5) {
1320
+ async _renameWithRetry(from, to, attempts = 8) {
1293
1321
  for (let i = 0; i < attempts; i++) {
1294
1322
  try {
1295
1323
  await fs.rename(from, to);
@@ -1297,7 +1325,8 @@ class StateManager {
1297
1325
  } catch (err) {
1298
1326
  const transient = err && (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY');
1299
1327
  if (!transient || i === attempts - 1) throw err;
1300
- await new Promise((r) => setTimeout(r, 15 * (i + 1)));
1328
+ // 40ms, 120ms, 270ms, 520ms, 900ms, 1.4s, 2.2s ~5.5s cumulative.
1329
+ await new Promise((r) => setTimeout(r, 40 * (i + 1) * (i + 1) * 0.75 + 10));
1301
1330
  }
1302
1331
  }
1303
1332
  }
package/src/index.js CHANGED
@@ -1012,7 +1012,16 @@ class LoxiaApplication {
1012
1012
  'ECONNRESET',
1013
1013
  'fetch failed',
1014
1014
  'AbortError',
1015
- 'The operation was aborted'
1015
+ 'The operation was aborted',
1016
+ // electron-updater failures (packaged app; backend shares the
1017
+ // process). A broken/missing release asset must NEVER kill the app:
1018
+ // v5.4.7's yml was published without its .exe and every installed
1019
+ // 5.4.5 crash-looped ~40s after launch on exactly this rejection.
1020
+ // The updater retries on its 6h cycle; crashing helps nobody.
1021
+ 'Cannot download',
1022
+ 'ERR_UPDATER',
1023
+ 'HttpError: 404',
1024
+ 'builder-util-runtime'
1016
1025
  ];
1017
1026
 
1018
1027
  const isNonCritical = nonCriticalPatterns.some(pattern =>