| 1 | using CascadeIDE.Cockpit.DataBus; |
| 2 | using CascadeIDE.Models; |
| 3 | using CascadeIDE.Services; |
| 4 | |
| 5 | namespace CascadeIDE.Features.Agent.Environment; |
| 6 | |
| 7 | public sealed record VerificationLadderResult( |
| 8 | bool Green, |
| 9 | string MaxRungReached, |
| 10 | IReadOnlyList<AgentTimeSlice> Slices, |
| 11 | string? FailureDetail); |
| 12 | |
| 13 | /// <summary>Verify rung climb (ADR 0148 W2).</summary> |
| 14 | public sealed class VerificationLadder |
| 15 | { |
| 16 | private readonly EnvironmentTaskRunner _runner; |
| 17 | private readonly AgentRoslynDiagnoseFilesDiagnostics _diagnoseFiles; |
| 18 | private readonly AgentEnvironmentSettings _settings; |
| 19 | private readonly AgentSandboxManager _sandbox; |
| 20 | private readonly EnvironmentTaskDedup _dedup; |
| 21 | private readonly IDataBus _dataBus; |
| 22 | private readonly IGitCommandRunner? _gitRunner; |
| 23 | private readonly Func<string?>? _getWorkspaceRoot; |
| 24 | |
| 25 | public VerificationLadder( |
| 26 | IDataBus dataBus, |
| 27 | IBuildTestHost host, |
| 28 | AgentRoslynDiagnoseFilesDiagnostics diagnoseFiles, |
| 29 | AgentEnvironmentSettings settings, |
| 30 | AgentSandboxManager sandbox, |
| 31 | IGitCommandRunner? gitRunner = null, |
| 32 | Func<string?>? getWorkspaceRoot = null) |
| 33 | { |
| 34 | _dataBus = dataBus; |
| 35 | _runner = new EnvironmentTaskRunner(dataBus, host.JobBackend); |
| 36 | _diagnoseFiles = diagnoseFiles; |
| 37 | _settings = settings; |
| 38 | _sandbox = sandbox; |
| 39 | _dedup = new EnvironmentTaskDedup(settings.CoalesceWindowMs); |
| 40 | _gitRunner = gitRunner; |
| 41 | _getWorkspaceRoot = getWorkspaceRoot; |
| 42 | } |
| 43 | |
| 44 | public EnvironmentTaskRunner Runner => _runner; |
| 45 | |
| 46 | public async Task<VerificationLadderResult> ClimbAsync( |
| 47 | string runId, |
| 48 | string solutionPath, |
| 49 | AgentVerifyPolicy policy, |
| 50 | AgentSandboxLease sandboxLease, |
| 51 | CancellationToken cancellationToken) |
| 52 | { |
| 53 | var slices = new List<AgentTimeSlice>(); |
| 54 | var envStart = DateTimeOffset.UtcNow; |
| 55 | var maxRung = VerifyRung.DiagnoseFiles; |
| 56 | var green = true; |
| 57 | string? failure = null; |
| 58 | |
| 59 | try |
| 60 | { |
| 61 | if (policy != AgentVerifyPolicy.Minimal && _settings.Ladder.DiagnoseFilesEnabled) |
| 62 | { |
| 63 | maxRung = VerifyRung.DiagnoseFiles; |
| 64 | var diagnoseStart = DateTimeOffset.UtcNow; |
| 65 | var diagnose = await _diagnoseFiles.RunAsync(cancellationToken).ConfigureAwait(false); |
| 66 | slices.Add(new AgentTimeSlice( |
| 67 | AgentRunPhaseKind.Environment, |
| 68 | (DateTimeOffset.UtcNow - diagnoseStart).TotalSeconds, |
| 69 | diagnose.Detail)); |
| 70 | green = diagnose.Green; |
| 71 | if (!green) |
| 72 | { |
| 73 | failure = diagnose.Detail; |
| 74 | return Finish(slices, envStart, maxRung, green, failure); |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | if (green && policy is not AgentVerifyPolicy.Minimal) |
| 79 | { |
| 80 | maxRung = VerifyRung.CompileProject; |
| 81 | slices.Add(new AgentTimeSlice( |
| 82 | AgentRunPhaseKind.Environment, |
| 83 | 0, |
| 84 | $"{VerifyRung.CompileProject}: delegated to {VerifyRung.BuildAffected} (MLP)")); |
| 85 | } |
| 86 | |
| 87 | if (green) |
| 88 | { |
| 89 | var dedupKey = $"build|{solutionPath}"; |
| 90 | var coalesced = _dedup.ShouldCoalesce(dedupKey); |
| 91 | if (!coalesced) |
| 92 | { |
| 93 | maxRung = VerifyRung.BuildAffected; |
| 94 | var build = await _runner.RunBuildAsync( |
| 95 | runId, |
| 96 | solutionPath, |
| 97 | waitForCompletion: true, |
| 98 | cancellationToken) |
| 99 | .ConfigureAwait(false); |
| 100 | green = build.Success; |
| 101 | if (!green) |
| 102 | failure = build.Status; |
| 103 | } |
| 104 | else |
| 105 | { |
| 106 | maxRung = VerifyRung.BuildAffected; |
| 107 | slices.Add(new AgentTimeSlice( |
| 108 | AgentRunPhaseKind.Environment, |
| 109 | 0, |
| 110 | $"{VerifyRung.BuildAffected}: build skipped (dedup/coalesce window; prior build assumed sufficient)")); |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | if (green && policy is AgentVerifyPolicy.Standard or AgentVerifyPolicy.Strict or AgentVerifyPolicy.CiParity) |
| 115 | { |
| 116 | sandboxLease = sandboxLease with |
| 117 | { |
| 118 | Substrate = _sandbox.RecreateSubstrateBeforeTests(sandboxLease), |
| 119 | }; |
| 120 | maxRung = VerifyRung.TestScoped; |
| 121 | |
| 122 | var contract = AgentDevServiceContractValidator.ValidateForTestScoped( |
| 123 | _settings.DevServices, |
| 124 | sandboxLease.Profile, |
| 125 | sandboxLease); |
| 126 | slices.Add(new AgentTimeSlice(AgentRunPhaseKind.Environment, 0, contract.Detail)); |
| 127 | if (!contract.Ok && _settings.DevServices.GateTestScopedOnViolation) |
| 128 | { |
| 129 | green = false; |
| 130 | failure = contract.Detail; |
| 131 | return Finish(slices, envStart, maxRung, green, failure); |
| 132 | } |
| 133 | |
| 134 | var supplementalEnv = sandboxLease.Substrate is null |
| 135 | ? null |
| 136 | : AgentSandboxProcessEnvironmentKeys.ForBundle(sandboxLease.Substrate); |
| 137 | |
| 138 | var filter = await AgentTestScopedTouchedTestFilter.BuildFilterExpressionAsync( |
| 139 | _settings.Ladder, |
| 140 | _gitRunner, |
| 141 | _getWorkspaceRoot?.Invoke(), |
| 142 | cancellationToken).ConfigureAwait(false); |
| 143 | |
| 144 | var test = await _runner.RunTestsAsync( |
| 145 | runId, |
| 146 | solutionPath, |
| 147 | filterExpression: filter, |
| 148 | waitForCompletion: true, |
| 149 | cancellationToken, |
| 150 | supplementalEnvironmentVariables: supplementalEnv).ConfigureAwait(false); |
| 151 | green = test.Success; |
| 152 | if (!green) |
| 153 | failure = test.Status; |
| 154 | } |
| 155 | |
| 156 | if (green && policy is AgentVerifyPolicy.CiParity) |
| 157 | { |
| 158 | maxRung = VerifyRung.TestFull; |
| 159 | if (_settings.Ladder.TestFullRequireExplicit) |
| 160 | slices.Add(new AgentTimeSlice( |
| 161 | AgentRunPhaseKind.Environment, |
| 162 | 0, |
| 163 | $"{VerifyRung.TestFull}: ci_parity marker (MLP)")); |
| 164 | } |
| 165 | } |
| 166 | catch (OperationCanceledException) |
| 167 | { |
| 168 | green = false; |
| 169 | failure = "cancelled"; |
| 170 | } |
| 171 | |
| 172 | return Finish(slices, envStart, maxRung, green, failure); |
| 173 | } |
| 174 | |
| 175 | private VerificationLadderResult Finish( |
| 176 | List<AgentTimeSlice> slices, |
| 177 | DateTimeOffset envStart, |
| 178 | string maxRung, |
| 179 | bool green, |
| 180 | string? failure) |
| 181 | { |
| 182 | var total = (DateTimeOffset.UtcNow - envStart).TotalSeconds; |
| 183 | if (!slices.Any(s => s.Detail?.StartsWith(VerifyRung.DiagnoseFiles, StringComparison.Ordinal) == true)) |
| 184 | slices.Insert(0, new AgentTimeSlice(AgentRunPhaseKind.Environment, total, $"ladder → {maxRung}")); |
| 185 | return new(green, maxRung, slices, failure); |
| 186 | } |
| 187 | } |
| 188 | |