diff --git a/scripts/desktop-update/retry-policy.ps1 b/scripts/desktop-update/retry-policy.ps1 new file mode 100644 index 0000000000..a36187b684 --- /dev/null +++ b/scripts/desktop-update/retry-policy.ps1 @@ -0,0 +1,15 @@ +function Test-HermesUpdateShouldRetry { + param( + [int]$ExitCode, + [string]$InstallRoot + ) + + if ($ExitCode -eq 0) { return $false } + if ($ExitCode -ne 2) { return $true } + + # Exit 2 is shared by non-retryable safety refusals and the self-lock + # deferral. Only the latter writes this marker, which a fresh Python + # process consumes before importing native modules. + $deferredInstallMarker = Join-Path $InstallRoot ".update-incomplete" + return Test-Path -LiteralPath $deferredInstallMarker +} diff --git a/scripts/desktop-update/windows.ps1 b/scripts/desktop-update/windows.ps1 index 5f442dac8c..67e6f8ab81 100644 --- a/scripts/desktop-update/windows.ps1 +++ b/scripts/desktop-update/windows.ps1 @@ -1498,10 +1498,13 @@ try { $res = Invoke-HermesStep $pythonExe $updateArgs "update" Write-HandoffLog "hermes update exit code: $($res.Code)" - if ($res.Code -ne 0 -and $res.Code -ne 2) { - # One retry for the update-boundary class (fresh code on disk, stale - # code in memory). Exit 2 ("close all Hermes windows") is not retryable. - Write-HandoffLog "first attempt failed; retrying once (freshly pulled fix loads on the second run)" + . (Join-Path $PSScriptRoot "retry-policy.ps1") + if (Test-HermesUpdateShouldRetry -ExitCode $res.Code -InstallRoot $InstallRoot) { + # One retry for update-boundary failures. Most exit-2 safety refusals + # remain terminal, but self-lock deferral also uses exit 2 and writes + # .update-incomplete after the code swap. A fresh process consumes that + # marker before native imports, then resumes the full update pipeline. + Write-HandoffLog "first attempt left retryable update state; retrying once in a fresh process" Publish-UiProgress "Retrying update" $res = Invoke-HermesStep $pythonExe $updateArgs "update" Write-HandoffLog "retry exit code: $($res.Code)" diff --git a/tests/test_desktop_update_windows_retry_policy.py b/tests/test_desktop_update_windows_retry_policy.py new file mode 100644 index 0000000000..25e07f3c40 --- /dev/null +++ b/tests/test_desktop_update_windows_retry_policy.py @@ -0,0 +1,56 @@ +"""Windows Desktop handoff retry policy behavior.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parent.parent +RETRY_POLICY = REPO_ROOT / "scripts" / "desktop-update" / "retry-policy.ps1" + + +@pytest.mark.windows_only +def test_retry_policy_distinguishes_self_lock_deferral(tmp_path: Path) -> None: + install_root = tmp_path / "hermes-agent" + install_root.mkdir() + marker = install_root / ".update-incomplete" + + policy = str(RETRY_POLICY).replace("'", "''") + root = str(install_root).replace("'", "''") + command = f""" + . '{policy}' + $withoutMarker = @( + (Test-HermesUpdateShouldRetry -ExitCode 0 -InstallRoot '{root}'), + (Test-HermesUpdateShouldRetry -ExitCode 1 -InstallRoot '{root}'), + (Test-HermesUpdateShouldRetry -ExitCode 2 -InstallRoot '{root}') + ) + New-Item -ItemType File -Path (Join-Path '{root}' '.update-incomplete') | Out-Null + $withMarker = Test-HermesUpdateShouldRetry -ExitCode 2 -InstallRoot '{root}' + @{{ withoutMarker = $withoutMarker; withMarker = $withMarker }} | + ConvertTo-Json -Compress + """ + result = subprocess.run( + [ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + command, + ], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert json.loads(result.stdout) == { + "withoutMarker": [False, True, False], + "withMarker": True, + } + assert marker.exists() \ No newline at end of file