Forge
csharpdeeb25a2
1#nullable enable
2
3using CascadeIDE.Cockpit.DataBus;
4using CascadeIDE.Features.Chat;
5using CascadeIDE.Features.HybridIndex.Application;
6using CascadeIDE.Models;
7using CascadeIDE.Services.Intercom;
8
9namespace CascadeIDE.Features.SolutionWarmup.Application;
10
11/// <summary>Фоновый прогрев при смене solution (ADR 0141).</summary>
12public sealed class SolutionWarmupOrchestrator : IDisposable
13{
14 private readonly IDataBus _dataBus;
15 private readonly SolutionWarmupHostCallbacks _host;
16 private readonly object _gate = new();
17 private CancellationTokenSource? _runCts;
18 private int _generation;
19 private SolutionWarmupScope _currentScope;
20
21 public SolutionWarmupOrchestrator(IDataBus dataBus, SolutionWarmupHostCallbacks host)
22 {
23 _dataBus = dataBus;
24 _host = host;
25 }
26
27 public void OnSolutionScopeChanged(string workspaceRoot, string? solutionPath)
28 {
29 var scope = new SolutionWarmupScope(
30 workspaceRoot.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
31 string.IsNullOrWhiteSpace(solutionPath) ? null : solutionPath.Trim());
32
33 lock (_gate)
34 {
35 cancelLocked(SolutionWarmupLifecycle.Cancelled, "scope_changed");
36 _currentScope = scope;
37 if (scope.IsEmpty)
38 {
39 publish(scope, SolutionWarmupLifecycle.Idle, null);
40 return;
41 }
42
43 var settings = _host.GetWarmupSettings();
44 if (!settings.Enabled)
45 {
46 publish(scope, SolutionWarmupLifecycle.Idle, "disabled");
47 return;
48 }
49
50 _generation++;
51 var gen = _generation;
52 _runCts = new CancellationTokenSource();
53 var token = _runCts.Token;
54 publish(scope, SolutionWarmupLifecycle.Running, null);
55 _ = Task.Run(() => runPipelineAsync(scope, gen, token), token);
56 }
57 }
58
59 private async Task runPipelineAsync(SolutionWarmupScope scope, int generation, CancellationToken cancellationToken)
60 {
61 var settings = _host.GetWarmupSettings();
62 var hybrid = _host.GetHybridIndexSettings();
63 var indexDir = HybridIndexIndexDirectoryRelative.ResolveOrDefault(hybrid.IndexDir);
64 var partial = false;
65
66 try
67 {
68 if (settings.WarmActiveFileOnSolutionOpen)
69 {
70 await runFileJobAsync(
71 () => warmBracketActiveFile(scope),
72 cancellationToken)
73 .ConfigureAwait(false);
74 throwIfStale(scope, generation, cancellationToken);
75 }
76
77 if (hybrid.Enabled && hybrid.AutoReindexOnSolutionOpen)
78 {
79 var hciOk = await waitForHybridIndexAsync(scope, cancellationToken).ConfigureAwait(false);
80 throwIfStale(scope, generation, cancellationToken);
81 if (!hciOk)
82 partial = true;
83 }
84 else if (!hybrid.Enabled)
85 {
86 IntercomSymbolLineIndexCoordinator.ScheduleRebuildAfterHybridIndex(
87 scope.WorkspaceRoot,
88 scope.SolutionPath,
89 indexDir);
90 }
91
92 if (settings.WarmFeedAnchorsAfterSymbolSidecar)
93 {
94 postFeedAnchors();
95 throwIfStale(scope, generation, cancellationToken);
96 }
97
98 if (settings.WarmOpenDocuments || settings.WarmRecentCsFiles)
99 {
100 var paths = collectOpenCsPaths(settings);
101 await warmFilesParallelAsync(scope, paths, settings, cancellationToken).ConfigureAwait(false);
102 throwIfStale(scope, generation, cancellationToken);
103 }
104
105 publishIfCurrent(
106 scope,
107 generation,
108 partial ? SolutionWarmupLifecycle.Partial : SolutionWarmupLifecycle.Ready,
109 partial ? "partial" : null);
110 }
111 catch (OperationCanceledException)
112 {
113 publishIfCurrent(scope, generation, SolutionWarmupLifecycle.Cancelled, "cancelled");
114 }
115 catch (Exception ex)
116 {
117 publishIfCurrent(scope, generation, SolutionWarmupLifecycle.Partial, ex.Message);
118 }
119 }
120
121 private void warmBracketActiveFile(SolutionWarmupScope scope)
122 {
123 var active = _host.GetActiveCsFilePath();
124 if (string.IsNullOrWhiteSpace(active))
125 return;
126 BracketMemberCompletionProvider.WarmIndex(active, scope.WorkspaceRoot);
127 }
128
129 private async Task<bool> waitForHybridIndexAsync(SolutionWarmupScope scope, CancellationToken cancellationToken)
130 {
131 var seed = _host.GetLatestHybridIndexState();
132 if (seed is not null
133 && scope.Matches(seed.WorkspaceRoot, seed.SolutionPath)
134 && string.IsNullOrWhiteSpace(seed.LastError)
135 && !string.IsNullOrWhiteSpace(seed.IndexedAtIso))
136 {
137 return true;
138 }
139
140 var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
141 using var sub = scopeSubscribe(scope, evt =>
142 {
143 if (string.IsNullOrWhiteSpace(evt.LastError))
144 tcs.TrySetResult(true);
145 else
146 tcs.TrySetResult(false);
147 });
148
149 using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
150 timeout.CancelAfter(TimeSpan.FromMinutes(30));
151 try
152 {
153 return await tcs.Task.WaitAsync(timeout.Token).ConfigureAwait(false);
154 }
155 catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
156 {
157 return false;
158 }
159 }
160
161 private IDisposable scopeSubscribe(SolutionWarmupScope scope, Action<HybridIndexStateChanged> handler) =>
162 _dataBus.Subscribe<HybridIndexStateChanged>(evt =>
163 {
164 if (!scope.Matches(evt.WorkspaceRoot, evt.SolutionPath))
165 return;
166 handler(evt);
167 });
168
169 private IReadOnlyList<string> collectOpenCsPaths(SolutionWarmupSettings settings)
170 {
171 if (!settings.WarmOpenDocuments && !settings.WarmRecentCsFiles)
172 return [];
173
174 var list = _host.GetOpenCsFilePaths();
175 var max = Math.Clamp(settings.MaxOpenDocumentFiles, 1, 32);
176 return list
177 .Where(p => p.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
178 .Distinct(StringComparer.OrdinalIgnoreCase)
179 .Take(max)
180 .ToList();
181 }
182
183 private async Task warmFilesParallelAsync(
184 SolutionWarmupScope scope,
185 IReadOnlyList<string> absolutePaths,
186 SolutionWarmupSettings settings,
187 CancellationToken cancellationToken)
188 {
189 if (absolutePaths.Count == 0)
190 return;
191
192 var parallel = Math.Clamp(settings.MaxParallelFileJobs, 1, 8);
193 using var gate = new SemaphoreSlim(parallel, parallel);
194 var cache = IntercomAttachResolveCacheContext.From(
195 scope.WorkspaceRoot,
196 scope.SolutionPath,
197 null,
198 HybridIndexIndexDirectoryRelative.ResolveOrDefault(_host.GetHybridIndexSettings().IndexDir));
199
200 var tasks = absolutePaths.Select(async path =>
201 {
202 await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
203 try
204 {
205 await Task.Run(
206 () =>
207 {
208 BracketMemberCompletionProvider.WarmIndex(path, scope.WorkspaceRoot);
209 IntercomRoslynL1Warmup.WarmFile(cache, path);
210 },
211 cancellationToken)
212 .ConfigureAwait(false);
213 }
214 finally
215 {
216 gate.Release();
217 }
218 });
219
220 await Task.WhenAll(tasks).ConfigureAwait(false);
221 }
222
223 private static async Task runFileJobAsync(Action work, CancellationToken cancellationToken) =>
224 await Task.Run(work, cancellationToken).ConfigureAwait(false);
225
226 private void postFeedAnchors() => _host.RunFeedAnchorsOnUi();
227
228 private void throwIfStale(SolutionWarmupScope scope, int generation, CancellationToken cancellationToken)
229 {
230 cancellationToken.ThrowIfCancellationRequested();
231 lock (_gate)
232 {
233 if (generation != _generation || !_currentScope.Equals(scope))
234 throw new OperationCanceledException();
235 }
236 }
237
238 private void publishIfCurrent(
239 SolutionWarmupScope scope,
240 int generation,
241 SolutionWarmupLifecycle lifecycle,
242 string? detail)
243 {
244 lock (_gate)
245 {
246 if (generation != _generation || !_currentScope.Equals(scope))
247 return;
248 publish(scope, lifecycle, detail);
249 }
250 }
251
252 private void cancelLocked(SolutionWarmupLifecycle lifecycle, string? detail)
253 {
254 _runCts?.Cancel();
255 _runCts?.Dispose();
256 _runCts = null;
257 if (!_currentScope.IsEmpty)
258 publish(_currentScope, lifecycle, detail);
259 }
260
261 private void publish(SolutionWarmupScope scope, SolutionWarmupLifecycle lifecycle, string? detail) =>
262 _dataBus.Publish(new SolutionWarmupStateChanged(
263 scope.WorkspaceRoot,
264 scope.SolutionPath,
265 lifecycle,
266 detail));
267
268 public void Dispose()
269 {
270 lock (_gate)
271 {
272 _runCts?.Cancel();
273 _runCts?.Dispose();
274 _runCts = null;
275 }
276 }
277}
278
View only · write via MCP/CIDE