diff --git a/scripts/reboot-recovery/agent99-live-preflight.ps1 b/scripts/reboot-recovery/agent99-live-preflight.ps1 index 7159a7474..52009db19 100644 --- a/scripts/reboot-recovery/agent99-live-preflight.ps1 +++ b/scripts/reboot-recovery/agent99-live-preflight.ps1 @@ -242,9 +242,10 @@ function Get-AgentBoundedNewestFiles { [int]$CandidateWindow = 32 ) - # Agent99 evidence names contain a fixed sortable timestamp and are immutable. - # Keep only the lexically newest bounded window in a native .NET set so a - # large evidence directory never becomes a PowerShell object-sort workload. + # Evidence freshness is authoritative from filesystem UTC mtime. Some + # evidence names are stable aliases and are not chronologically sortable. + # Keep only the newest bounded window in a native .NET set so a large + # evidence directory never becomes a PowerShell object-sort workload. $selected = [Collections.Generic.SortedSet[string]]::new( [StringComparer]::OrdinalIgnoreCase ) @@ -256,16 +257,25 @@ function Get-AgentBoundedNewestFiles { [IO.SearchOption]::TopDirectoryOnly )) { $enumeratedCount += 1 - $null = $selected.Add($candidatePath) + $candidate = [IO.FileInfo]::new($candidatePath) + $sortKey = ( + $candidate.LastWriteTimeUtc.Ticks.ToString( + "D19", + [Globalization.CultureInfo]::InvariantCulture + ) + "|" + $candidate.FullName + ) + $null = $selected.Add($sortKey) if ($selected.Count -gt $CandidateWindow) { $null = $selected.Remove($selected.Min) } } } + $selectedKeys = [string[]]@($selected) + [Array]::Reverse($selectedKeys) return [pscustomobject]@{ - files = @($selected | Sort-Object -Descending | ForEach-Object { - [IO.FileInfo]::new($_) + files = @($selectedKeys | ForEach-Object { + [IO.FileInfo]::new($_.Substring(20)) }) enumeratedCount = [int]$enumeratedCount candidateWindow = [int]$CandidateWindow @@ -685,7 +695,11 @@ function Get-EvidenceReadback { $deferredCandidates.Count -eq $candidates.Count ) $ageMinutes = if ($file) { [math]::Round(((Get-Date) - $file.LastWriteTime).TotalMinutes, 1) } else { $null } - $fresh = [bool]($file -and $ageMinutes -le $MaxAgeMinutes) + $fresh = [bool]( + $file -and + $ageMinutes -ge 0 -and + $ageMinutes -le $MaxAgeMinutes + ) $freshnessHeadroomMinutes = if ($file) { [math]::Round($MaxAgeMinutes - $ageMinutes, 1) } else { $null } $freshnessReserveReady = [bool]( $fresh -and 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 17cf2cfd3..e182cf040 100644 --- a/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py +++ b/scripts/reboot-recovery/tests/test_agent99_live_preflight_decision.py @@ -156,6 +156,11 @@ def test_warning_receipt_is_visible_without_weakening_integrity_blockers() -> No assert "deferredCandidateCount = [int]$deferredCandidates.Count" in source assert "[IO.Directory]::EnumerateFiles(" in source assert "[Collections.Generic.SortedSet[string]]::new(" in source + assert "$candidate.LastWriteTimeUtc.Ticks.ToString(" in source + assert "[Globalization.CultureInfo]::InvariantCulture" in source + assert "[IO.FileInfo]::new($_.Substring(20))" in source + assert "[Array]::Reverse($selectedKeys)" in source + assert "$ageMinutes -ge 0" in source assert "$null = $selected.Remove($selected.Min)" in source assert "candidateWindowExhausted = $candidateWindowExhausted" in source assert "function Get-AgentControlLoopMaintenanceReadback" in source @@ -278,6 +283,141 @@ Get-EvidenceReadback 'self_check' 'agent99-SelfCheck-*.json' 20 $true | assert malformed_payload["healthy"] is False +@pytest.mark.parametrize( + ("name", "pattern", "stale_alias", "fresh_file"), + [ + ( + "status", + "agent99-Status-*.json", + "agent99-Status-same-run-status-{index:02d}.json", + "agent99-Status-20260722-133422.json", + ), + ( + "performance", + "agent99-Perf-*.json", + "agent99-Perf-latest-summary-{index:02d}.json", + "agent99-Perf-20260722-133722.json", + ), + ], +) +def test_evidence_selection_uses_mtime_when_stable_aliases_sort_after_timestamps( + tmp_path: Path, + name: str, + pattern: str, + stale_alias: str, + fresh_file: str, +) -> 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() + fresh = evidence / fresh_file + fresh.write_text(json.dumps({"mode": name}), encoding="utf-8") + base_time = fresh.stat().st_mtime + for index in range(40): + alias = evidence / stale_alias.format(index=index) + alias.write_text(json.dumps({"mode": name}), encoding="utf-8") + stale_time = base_time - 3600 - index + os.utime(alias, (stale_time, stale_time)) + os.utime(fresh, (base_time, base_time)) + + agent_root = str(tmp_path).replace("'", "''") + command = f""" +{_evidence_function_source()} +$AgentRoot = '{agent_root}' +Get-EvidenceReadback '{name}' '{pattern}' 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"] == fresh.name + assert payload["fresh"] is True + assert payload["candidateWindowTruncated"] is True + + +def test_evidence_selection_rejects_future_mtime(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() + future = evidence / "agent99-Status-20260722-140000.json" + future.write_text(json.dumps({"mode": "Status"}), encoding="utf-8") + future_time = future.stat().st_mtime + 120 + os.utime(future, (future_time, future_time)) + + agent_root = str(tmp_path).replace("'", "''") + command = f""" +{_evidence_function_source()} +$AgentRoot = '{agent_root}' +Get-EvidenceReadback 'status' 'agent99-Status-*.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"] == future.name + assert payload["ageMinutes"] < 0 + assert payload["fresh"] is False + assert payload["usable"] is False + assert payload["healthy"] is False + + +def test_evidence_selection_equal_mtime_uses_ordinal_path_tie_break( + 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() + lower = evidence / "agent99-Status-equal-a.json" + higher = evidence / "agent99-Status-equal-z.json" + lower.write_text(json.dumps({"mode": "Status"}), encoding="utf-8") + higher.write_text(json.dumps({"mode": "Status"}), encoding="utf-8") + equal_time = lower.stat().st_mtime + os.utime(lower, (equal_time, equal_time)) + os.utime(higher, (equal_time, equal_time)) + + agent_root = str(tmp_path).replace("'", "''") + command = f""" +{_evidence_function_source()} +$AgentRoot = '{agent_root}' +Get-EvidenceReadback 'status' 'agent99-Status-*.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"] == higher.name + + def test_evidence_reserve_rejects_fresh_but_expiring_evidence_without_extending_slo( tmp_path: Path, ) -> None: