diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 06a1eb9181..51607c1397 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -3707,16 +3707,20 @@ async function handOffWindowsBootstrapRecovery(reason) { const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes') const venvPython = path.join(venvBin, IS_WINDOWS ? 'python.exe' : 'python') - // Choose the gentle in-place --update when ANY real-install signal is present, - // not just the `hermes.exe` console-script shim. That shim is generated at the - // END of venv setup and is absent in exactly the interrupted/quarantined states - // this recovery exists to heal — gating on it alone forced the destructive - // --repair (full venv recreate) and drove reinstall loops. The venv interpreter - // and the bootstrap-complete marker are present earlier and are better signals. - const haveRealInstall = - fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')) - - const updaterArgs = chooseUpdaterArgs(haveRealInstall, branch) + // The updater invokes the venv's Hermes launcher, which in turn requires the + // venv interpreter. A bootstrap-complete marker proves only that setup once + // finished; it can outlive a manually removed or quarantined venv. Sending a + // marker-only install through --update dead-ends at "Could not find the hermes + // CLI" instead of rebuilding the runtime, so only a runnable pair gets the + // gentle update path. Partial or missing runtimes go through full repair. + const updaterArgs = chooseUpdaterArgs( + { + hasBootstrapMarker: fileExists(path.join(updateRoot, '.hermes-bootstrap-complete')), + hasVenvHermes: fileExists(venvHermes), + hasVenvPython: fileExists(venvPython) + }, + branch + ) await releaseBackendLockForUpdate(updateRoot) diff --git a/apps/desktop/electron/windows-hermes-path.test.ts b/apps/desktop/electron/windows-hermes-path.test.ts index 778042b278..76e4d0ac50 100644 --- a/apps/desktop/electron/windows-hermes-path.test.ts +++ b/apps/desktop/electron/windows-hermes-path.test.ts @@ -5,9 +5,9 @@ // 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the // empty extension, or an extensionless Git-Bash `hermes` shim shadows // the real hermes.cmd/hermes.exe. -// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install -// signal), not just the hermes.exe console-script shim, or healthy -// installs get forced into a destructive --repair. +// 2. chooseUpdaterArgs() — must distinguish a runnable updater from stale +// install provenance. The bootstrap marker can outlive the venv, and a +// partial venv cannot run the updater; those states require --repair. // 3. resolveVenvHermesCommand() — must probe the venv python via // canImportHermesCli() before trusting it, or a broken venv gets // re-selected forever instead of falling through to bootstrap. @@ -45,17 +45,40 @@ test('buildPathExtCandidates: non-Windows only tries the bare name', () => { assert.deepEqual(buildPathExtCandidates(undefined, false), ['']) }) -test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => { - assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main']) +test('chooseUpdaterArgs: gentle --update when both updater runtime files exist', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: true }, 'main'), + ['--update', '--branch', 'main'] + ) }) -test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => { - assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main']) +test('chooseUpdaterArgs: marker-only install uses --repair when the venv is gone', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: false }, 'main'), + ['--repair', '--branch', 'main'] + ) }) -test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => { - assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2']) - assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2']) +test('chooseUpdaterArgs: partial updater runtimes use --repair', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: true }, 'main'), + ['--repair', '--branch', 'main'] + ) + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: false }, 'main'), + ['--repair', '--branch', 'main'] + ) +}) + +test('chooseUpdaterArgs: passes the branch through unchanged in both modes', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: true, hasVenvPython: true }, 'release/1.2'), + ['--update', '--branch', 'release/1.2'] + ) + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: false, hasVenvPython: false }, 'release/1.2'), + ['--repair', '--branch', 'release/1.2'] + ) }) function makeDeps(overrides: Partial[2]> = {}) { diff --git a/apps/desktop/electron/windows-hermes-path.ts b/apps/desktop/electron/windows-hermes-path.ts index 6f3542853e..f94d4b2c39 100644 --- a/apps/desktop/electron/windows-hermes-path.ts +++ b/apps/desktop/electron/windows-hermes-path.ts @@ -11,12 +11,11 @@ * hermes.cmd/hermes.exe; the shim then failed the --version probe and * the desktop fell through to a spurious bootstrap/repair. The fix: * PATHEXT extensions first, empty extension LAST. - * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose - * --update vs the destructive --repair by checking ONLY - * venv\Scripts\hermes.exe (the console-script shim, written at the END - * of venv setup and absent in interrupted states), so it escalated to a - * full venv recreate even on healthy installs. The fix: gate on ANY - * real-install signal, not just the shim. + * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() must separate + * install provenance from updater viability. A bootstrap-complete marker + * can outlive a deleted venv, while the updater needs BOTH the venv Python + * and Hermes launcher. Marker-only or partial runtimes must use --repair; + * only a runnable pair can use --update. * 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned * the venv python with NO runtime probe (bypassing the caller's * --version check too), so a venv broken mid-update (e.g. missing @@ -61,23 +60,26 @@ export function buildPathExtCandidates(pathext: string | undefined, isWindows: b } /** - * Choose the Windows bootstrap-recovery updater invocation: the gentle - * in-place --update when ANY real-install signal is present, the - * destructive --repair (full venv recreate) otherwise. + * Choose the Windows bootstrap-recovery invocation. The gentle in-place + * updater can only start when both pieces of its runtime contract exist: the + * venv Python interpreter and the Hermes launcher that drives `hermes update`. + * A bootstrap-complete marker proves install provenance, not current runtime + * usability, and may remain after the venv is removed or quarantined. * - * haveRealInstall must be computed by the caller from ALL real-install - * signals (venv python interpreter, venv hermes shim, bootstrap-complete - * marker) — gating on just the hermes.exe console-script shim alone is the - * regression this function's callers must avoid: that shim is written at - * the END of venv setup and is absent in exactly the interrupted/quarantined - * states this recovery exists to heal. - * - * @param {boolean} haveRealInstall + * @param {BootstrapRecoverySignals} signals * @param {string} branch * @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main']. */ -export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] { - return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] +export interface BootstrapRecoverySignals { + hasBootstrapMarker: boolean + hasVenvHermes: boolean + hasVenvPython: boolean +} + +export function chooseUpdaterArgs(signals: BootstrapRecoverySignals, branch: string): string[] { + const canRunUpdater = signals.hasVenvHermes && signals.hasVenvPython + + return canRunUpdater ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] } /**