From aceedcc202133cfbd2a4ee39781159d4af6c5109 Mon Sep 17 00:00:00 2001 From: ogt Date: Sat, 11 Jul 2026 20:58:00 +0800 Subject: [PATCH] fix(agent99): harden runtime deploy rollback --- agent99-deploy.ps1 | 44 +++++++++++++++---- ...nt99_transport_recovery_deploy_contract.py | 19 ++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/agent99-deploy.ps1 b/agent99-deploy.ps1 index 5fddb65ba..f3b3f0eee 100644 --- a/agent99-deploy.ps1 +++ b/agent99-deploy.ps1 @@ -18,6 +18,7 @@ $BackupDir = Join-Path $DeployRoot "backup-$stamp" $ManifestPath = Join-Path $StateDir "runtime-manifest.json" $EvidencePath = Join-Path $EvidenceDir "agent99-deploy-$stamp.json" $script:ConfigMigrations = @() +$script:RuntimeCopyRetries = @() $runtimeFiles = @( "agent99-bootstrap.ps1", @@ -64,6 +65,29 @@ function Set-AgentProperty { } } +function Copy-AgentRuntimeFileWithRetry { + param( + [string]$Source, + [string]$Destination, + [int]$MaxAttempts = 20, + [int]$DelayMilliseconds = 500 + ) + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt += 1) { + try { + Copy-Item -Force $Source $Destination + return + } catch [System.IO.IOException] { + if ($attempt -ge $MaxAttempts) { throw } + $script:RuntimeCopyRetries += [pscustomobject]@{ + file = Split-Path -Leaf $Destination + attempt = $attempt + reason = "file_in_use" + } + Start-Sleep -Milliseconds $DelayMilliseconds + } + } +} + function Write-DeployEvidence { param([bool]$Ok, [string]$Status, [string]$ErrorText, [object[]]$Files, [object]$TaskResult) New-Item -ItemType Directory -Force -Path $EvidenceDir | Out-Null @@ -82,6 +106,8 @@ function Write-DeployEvidence { registerTasks = [bool]$RegisterTasks error = $ErrorText configMigrations = @($script:ConfigMigrations) + runtimeCopyRetryCount = @($script:RuntimeCopyRetries).Count + runtimeCopyRetries = @($script:RuntimeCopyRetries) files = $Files taskRegistration = $TaskResult } | ConvertTo-Json -Depth 10 | Set-Content -Path $EvidencePath -Encoding UTF8 @@ -92,7 +118,7 @@ function Restore-Agent99Deployment { foreach ($item in $Rows) { $backupPath = Join-Path $BackupDir $item.name if ($item.existed -and (Test-Path $backupPath)) { - Copy-Item -Force $backupPath $item.livePath + Copy-AgentRuntimeFileWithRetry $backupPath $item.livePath } elseif (-not $item.existed -and (Test-Path $item.livePath)) { Remove-Item -Force $item.livePath } @@ -151,14 +177,6 @@ $promotionRows = @() $configBackedUp = $false $manifestBackedUp = $false try { - foreach ($name in $runtimeFiles) { - $livePath = Join-Path $BinDir $name - $existed = Test-Path $livePath - if ($existed) { Copy-Item -Force $livePath (Join-Path $BackupDir $name) } - $promotionRows += [pscustomobject]@{ name = $name; livePath = $livePath; existed = [bool]$existed } - Copy-Item -Force (Join-Path $StageDir $name) $livePath - } - if (-not (Test-Path $ConfigPath)) { throw "Live Agent99 config not found: $ConfigPath" } Copy-Item -Force $ConfigPath (Join-Path $BackupDir "agent99.config.json") $configBackedUp = $true @@ -167,6 +185,14 @@ try { $manifestBackedUp = $true } + foreach ($name in $runtimeFiles) { + $livePath = Join-Path $BinDir $name + $existed = Test-Path $livePath + if ($existed) { Copy-Item -Force $livePath (Join-Path $BackupDir $name) } + $promotionRows += [pscustomobject]@{ name = $name; livePath = $livePath; existed = [bool]$existed } + Copy-AgentRuntimeFileWithRetry (Join-Path $StageDir $name) $livePath + } + $config = Get-Content $ConfigPath -Raw | ConvertFrom-Json Add-DefaultProperty $config "sshIdentityFile" (Join-Path $AgentRoot "keys\agent99_ed25519") Add-DefaultProperty $config "sshTransport" ([pscustomobject]@{}) diff --git a/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py b/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py index 30084e9b5..1e93e0638 100644 --- a/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py +++ b/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py @@ -164,6 +164,25 @@ def test_agent99_deployer_verifies_promoted_runtime_and_writes_revision() -> Non assert f'"{name}"' in bootstrap +def test_agent99_deployer_backs_up_manifest_before_runtime_promotion() -> None: + deploy = DEPLOY.read_text(encoding="utf-8") + promotion = deploy[deploy.index("$configBackedUp = $false") :] + promotion = promotion[: promotion.index("$config = Get-Content $ConfigPath")] + + assert promotion.index('Copy-Item -Force $ConfigPath') < promotion.index( + "foreach ($name in $runtimeFiles)" + ) + assert promotion.index('Copy-Item -Force $ManifestPath') < promotion.index( + "foreach ($name in $runtimeFiles)" + ) + assert 'Restore-Agent99Deployment $promotionRows $configBackedUp $manifestBackedUp' in deploy + assert "function Copy-AgentRuntimeFileWithRetry" in deploy + assert "MaxAttempts = 20" in deploy + assert 'reason = "file_in_use"' in deploy + assert "runtimeCopyRetryCount" in deploy + assert "Copy-AgentRuntimeFileWithRetry (Join-Path $StageDir $name) $livePath" in deploy + + def test_agent99_completion_callback_waits_for_durable_same_trace_readback() -> None: source = CONTROL.read_text(encoding="utf-8") deploy = DEPLOY.read_text(encoding="utf-8")