From 76caef127023293abc0eb21bb904d3e432c7c393 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 15 Jul 2026 21:03:45 +0800 Subject: [PATCH] fix(agent99): skip deferred preflight evidence --- .../agent99-live-preflight.ps1 | 37 +++++++- .../test_agent99_live_preflight_decision.py | 94 +++++++++++++++++++ 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/scripts/reboot-recovery/agent99-live-preflight.ps1 b/scripts/reboot-recovery/agent99-live-preflight.ps1 index ecf9049c7..7821c2427 100644 --- a/scripts/reboot-recovery/agent99-live-preflight.ps1 +++ b/scripts/reboot-recovery/agent99-live-preflight.ps1 @@ -169,9 +169,38 @@ function Get-EvidenceReadback { ) $evidenceDir = Join-Path $AgentRoot "evidence" - $file = Get-ChildItem -Path $evidenceDir -Filter $Pattern -File -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 + $candidates = @(Get-ChildItem -Path $evidenceDir -Filter $Pattern -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending) + $deferredCandidates = @() + $file = $null + foreach ($candidate in $candidates) { + $candidateDeferred = $false + try { + $candidateEvidence = Get-Content -LiteralPath $candidate.FullName -Raw | ConvertFrom-Json + $candidateSchemaVersion = if ($candidateEvidence.PSObject.Properties["schemaVersion"]) { + [string]$candidateEvidence.schemaVersion + } else { + "" + } + $candidateDeferred = [bool]( + ($candidateEvidence.PSObject.Properties["deferred"] -and [bool]$candidateEvidence.deferred) -or + $candidateSchemaVersion -in @( + "agent99_background_mode_deferred_v1", + "agent99_recovery_transport_deferred_v1" + ) + ) + } catch { + # A malformed newest non-deferred candidate must remain visible and fail closed. + $file = $candidate + break + } + if ($candidateDeferred) { + $deferredCandidates += $candidate + continue + } + $file = $candidate + break + } $ageMinutes = if ($file) { [math]::Round(((Get-Date) - $file.LastWriteTime).TotalMinutes, 1) } else { $null } $fresh = [bool]($file -and $ageMinutes -le $MaxAgeMinutes) $safeSummary = $null @@ -331,6 +360,8 @@ function Get-EvidenceReadback { usable = $usable healthy = [bool](-not $Required -or $usable) parseError = $parseError + deferredCandidateCount = [int]$deferredCandidates.Count + latestDeferredTime = if ($deferredCandidates.Count -gt 0) { $deferredCandidates[0].LastWriteTime.ToString("o") } else { "" } safeSummary = $safeSummary } } diff --git a/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py b/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py index a2acf14d8..a8d1cefe6 100644 --- a/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py +++ b/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import shutil import subprocess from copy import deepcopy @@ -21,6 +22,13 @@ def _function_source() -> str: return source[start:end] +def _evidence_function_source() -> str: + source = PREFLIGHT.read_text(encoding="utf-8") + start = source.index("function Get-EvidenceReadback") + end = source.index("$requiredTasks = @(", start) + return source[start:end] + + def _base_case() -> dict[str, Any]: return { "agentRootPresent": True, @@ -127,6 +135,92 @@ def test_warning_receipt_is_visible_without_weakening_integrity_blockers() -> No ) assert 'if ($result.summary.deploymentEligible)' in source assert 'if ($selfHealthSeverity -ne "ok")' not in source + assert '"agent99_background_mode_deferred_v1"' in source + assert '"agent99_recovery_transport_deferred_v1"' in source + assert "deferredCandidateCount = [int]$deferredCandidates.Count" in source + + +def test_evidence_selection_skips_deferred_but_not_malformed_candidates( + tmp_path: Path, +) -> None: + powershell = shutil.which("pwsh") or shutil.which("powershell") + if powershell is None: + pytest.skip("PowerShell runtime is not installed on this macOS verifier") + + evidence = tmp_path / "evidence" + evidence.mkdir() + canonical = evidence / "agent99-SelfCheck-20260715-200000.json" + deferred = evidence / "agent99-SelfCheck-20260715-200500.json" + canonical.write_text( + json.dumps( + { + "mode": "SelfCheck", + "selfHealth": { + "severity": "warning", + "latestPerformance": {"severity": "ok", "hosts": []}, + }, + } + ), + encoding="utf-8", + ) + deferred.write_text( + json.dumps( + { + "schemaVersion": "agent99_background_mode_deferred_v1", + "mode": "SelfCheck", + "deferred": True, + } + ), + encoding="utf-8", + ) + now = canonical.stat().st_mtime + os.utime(canonical, (now - 10, now - 10)) + os.utime(deferred, (now, now)) + + agent_root = str(tmp_path).replace("'", "''") + command = f""" +{_evidence_function_source()} +$AgentRoot = '{agent_root}' +Get-EvidenceReadback 'self_check' 'agent99-SelfCheck-*.json' 20 $true | + ConvertTo-Json -Depth 8 -Compress +""" + result = subprocess.run( + [powershell, "-NoProfile", "-NonInteractive", "-Command", command], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + payload = json.loads( + next(line for line in reversed(result.stdout.splitlines()) if line.startswith("{")) + ) + assert payload["file"] == canonical.name + assert payload["deferredCandidateCount"] == 1 + assert payload["parsed"] is True + assert payload["safeSummary"]["selfHealthSeverity"] == "warning" + + malformed = evidence / "agent99-SelfCheck-20260715-201000.json" + malformed.write_text("{not-json", encoding="utf-8") + os.utime(malformed, (now + 10, now + 10)) + malformed_result = subprocess.run( + [powershell, "-NoProfile", "-NonInteractive", "-Command", command], + check=False, + capture_output=True, + text=True, + ) + assert malformed_result.returncode == 0, ( + malformed_result.stdout + malformed_result.stderr + ) + malformed_payload = json.loads( + next( + line + for line in reversed(malformed_result.stdout.splitlines()) + if line.startswith("{") + ) + ) + assert malformed_payload["file"] == malformed.name + assert malformed_payload["parsed"] is False + assert malformed_payload["healthy"] is False def test_decision_behavior_when_powershell_is_available() -> None: