44using GsdOrchestrator . Mcp ;
55using GsdOrchestrator . Scheduling ;
66using GsdOrchestrator . Verification ;
7+ using Microsoft . Extensions . Logging ;
78
89namespace GsdOrchestrator . Loop ;
910
@@ -87,7 +88,7 @@ public async Task PublishAsync(TerminalLoopOutcome outcome, CancellationToken ca
8788 }
8889}
8990
90- public sealed class LoopCoordinator ( IGoalStore store , ILoopWorker worker , ILoopVerifier verifier , ITerminalOutcomePublisher learning )
91+ public sealed class LoopCoordinator ( IGoalStore store , ILoopWorker worker , ILoopVerifier verifier , ITerminalOutcomePublisher learning , ILogger < LoopCoordinator > logger )
9192{
9293 public async Task < LoopRunResult > RunAsync (
9394 string goalId ,
@@ -107,55 +108,93 @@ public async Task<LoopRunResult> RunAsync(
107108 ?? throw new InvalidOperationException ( $ "Work item '{ workItem . Id } ' could not be leased.") ;
108109
109110 aggregate = ( await store . LoadAsync ( goalId , cancellationToken ) ) ! ;
110- var first = await ExecuteAttemptAsync ( aggregate , workItem , 1 , false , cancellationToken ) ;
111- aggregate = first . Aggregate ;
112- var workerAttempts = 1 ;
113- var peakConcurrency = first . Work . PeakConcurrency ;
111+ var workerAttempts = 0 ;
112+ var peakConcurrency = 0 ;
114113 var verificationRuns = 0 ;
115114 var repairCreated = false ;
116115
117- if ( first . Work . Succeeded )
116+ try
118117 {
119- var verification = await verifier . VerifyAsync ( goalId , first . Work , cancellationToken ) ;
120- verificationRuns ++ ;
121- aggregate = AddVerification ( aggregate , workItem . Id , verification ) ;
122- if ( verification . Outcome == VerificationOutcome . Failed )
118+ var first = await ExecuteAttemptAsync ( aggregate , workItem , 1 , false , cancellationToken ) ;
119+ aggregate = first . Aggregate ;
120+ workerAttempts = 1 ;
121+ peakConcurrency = first . Work . PeakConcurrency ;
122+
123+ if ( first . Work . Succeeded )
123124 {
124- var consumedBudget = repairBudget with
125- {
126- ConsumedAttempts = Math . Max ( repairBudget . ConsumedAttempts , 1 ) ,
127- ConsumedIterations = Math . Max ( repairBudget . ConsumedIterations , 1 ) ,
128- } ;
129- var repair = RepairPolicy . CreateRepair ( verification , consumedBudget , workItem . Id ) ;
130- if ( repair is not null )
125+ var verification = await verifier . VerifyAsync ( goalId , first . Work , cancellationToken ) ;
126+ verificationRuns ++ ;
127+ aggregate = AddVerification ( aggregate , workItem . Id , verification ) ;
128+ if ( verification . Outcome == VerificationOutcome . Failed )
131129 {
132- repairCreated = true ;
133- aggregate = AddEvent ( aggregate , "repair.created" , $ "attempt-{ repair . AttemptNumber } ") ;
134- await store . SaveAsync ( aggregate , cancellationToken ) ;
135- var repaired = await ExecuteAttemptAsync ( aggregate , workItem , repair . AttemptNumber , true , cancellationToken ) ;
136- aggregate = repaired . Aggregate ;
137- workerAttempts ++ ;
138- peakConcurrency = Math . Max ( peakConcurrency , repaired . Work . PeakConcurrency ) ;
139- if ( repaired . Work . Succeeded )
130+ var consumedBudget = repairBudget with
131+ {
132+ ConsumedAttempts = Math . Max ( repairBudget . ConsumedAttempts , 1 ) ,
133+ ConsumedIterations = Math . Max ( repairBudget . ConsumedIterations , 1 ) ,
134+ } ;
135+ var repair = RepairPolicy . CreateRepair ( verification , consumedBudget , workItem . Id ) ;
136+ if ( repair is not null )
140137 {
141- verification = await verifier . VerifyAsync ( goalId , repaired . Work , cancellationToken ) ;
142- verificationRuns ++ ;
143- aggregate = AddVerification ( aggregate , workItem . Id , verification ) ;
138+ repairCreated = true ;
139+ aggregate = AddEvent ( aggregate , "repair.created" , $ "attempt-{ repair . AttemptNumber } ") ;
140+ await store . SaveAsync ( aggregate , cancellationToken ) ;
141+ var repaired = await ExecuteAttemptAsync ( aggregate , workItem , repair . AttemptNumber , true , cancellationToken ) ;
142+ aggregate = repaired . Aggregate ;
143+ workerAttempts ++ ;
144+ peakConcurrency = Math . Max ( peakConcurrency , repaired . Work . PeakConcurrency ) ;
145+ if ( repaired . Work . Succeeded )
146+ {
147+ verification = await verifier . VerifyAsync ( goalId , repaired . Work , cancellationToken ) ;
148+ verificationRuns ++ ;
149+ aggregate = AddVerification ( aggregate , workItem . Id , verification ) ;
150+ }
144151 }
145152 }
146153 }
154+
155+ var outcome = aggregate . Evidence . Any ( item => item . Kind == "verification-failed" )
156+ && ! aggregate . Evidence . Any ( item => item . Kind == "verification-passed" && item . Id . EndsWith ( $ "-{ verificationRuns } ", StringComparison . Ordinal ) )
157+ ? GoalStatus . Failed
158+ : aggregate . Evidence . Any ( item => item . Kind == "verification-passed" ) ? GoalStatus . Completed : GoalStatus . Failed ;
159+ aggregate = Complete ( aggregate , workItem . Id , outcome , lease . Id ) ;
160+ await store . SaveAsync ( aggregate , cancellationToken ) ;
161+
162+ var evidence = aggregate . Evidence . Select ( item => item . Uri ) . Distinct ( StringComparer . Ordinal ) . ToArray ( ) ;
163+ await learning . PublishAsync ( new ( goalId , aggregate . Goal . CorrelationId , outcome , $ "Goal { outcome } after { workerAttempts } worker attempt(s).", evidence ) , cancellationToken ) ;
164+ return new ( aggregate , workerAttempts , verificationRuns , repairCreated , peakConcurrency ) ;
165+ }
166+ catch ( OperationCanceledException ) when ( cancellationToken . IsCancellationRequested )
167+ {
168+ var failure = FailureClassifier . Classify ( new OperationCanceledException ( "Loop execution cancelled by caller." ) , "gsd-orchestrator.LoopCoordinator" ) ;
169+ logger . LogError (
170+ "Loop execution failed for goal {GoalId} with failure class {FailureClass}: {Message}" ,
171+ goalId ,
172+ failure . FailureClass ,
173+ failure . Message ) ;
174+ throw ;
147175 }
176+ catch ( Exception ex )
177+ {
178+ var failure = FailureClassifier . Classify ( ex , "gsd-orchestrator.LoopCoordinator" ) ;
179+ logger . LogError (
180+ ex ,
181+ "Loop execution failed for goal {GoalId} with failure class {FailureClass}: {Message}" ,
182+ goalId ,
183+ failure . FailureClass ,
184+ failure . Message ) ;
148185
149- var outcome = aggregate . Evidence . Any ( item => item . Kind == "verification-failed" )
150- && ! aggregate . Evidence . Any ( item => item . Kind == "verification-passed" && item . Id . EndsWith ( $ "-{ verificationRuns } ", StringComparison . Ordinal ) )
151- ? GoalStatus . Failed
152- : aggregate . Evidence . Any ( item => item . Kind == "verification-passed" ) ? GoalStatus . Completed : GoalStatus . Failed ;
153- aggregate = Complete ( aggregate , workItem . Id , outcome , lease . Id ) ;
154- await store . SaveAsync ( aggregate , cancellationToken ) ;
186+ aggregate = await store . LoadAsync ( goalId , cancellationToken ) ?? aggregate ;
187+ var status = failure . FailureClass == FailureClass . Cancellation ? GoalStatus . Cancelled : GoalStatus . Failed ;
188+ var evidenceUri = $ "cas://evidence/failure/{ failure . FailureClass . ToString ( ) . ToLowerInvariant ( ) } /{ Guid . NewGuid ( ) : N} ";
189+ aggregate = CompleteWithFailure ( aggregate , workItem . Id , lease . Id , status , failure , evidenceUri ) ;
190+ await store . SaveAsync ( aggregate , cancellationToken ) ;
155191
156- var evidence = aggregate . Evidence . Select ( item => item . Uri ) . Distinct ( StringComparer . Ordinal ) . ToArray ( ) ;
157- await learning . PublishAsync ( new ( goalId , aggregate . Goal . CorrelationId , outcome , $ "Goal { outcome } after { workerAttempts } worker attempt(s).", evidence ) , cancellationToken ) ;
158- return new ( aggregate , workerAttempts , verificationRuns , repairCreated , peakConcurrency ) ;
192+ var evidence = aggregate . Evidence . Select ( item => item . Uri ) . Distinct ( StringComparer . Ordinal ) . ToArray ( ) ;
193+ await learning . PublishAsync (
194+ new ( goalId , aggregate . Goal . CorrelationId , status , failure . Message , evidence ) ,
195+ cancellationToken ) ;
196+ return new ( aggregate , workerAttempts , verificationRuns , repairCreated , peakConcurrency ) ;
197+ }
159198 }
160199
161200 private async Task < ( GoalAggregate Aggregate , LoopWorkResult Work ) > ExecuteAttemptAsync (
@@ -215,6 +254,46 @@ private static GoalAggregate Complete(GoalAggregate aggregate, string workItemId
215254 } ;
216255 }
217256
257+ private static GoalAggregate CompleteWithFailure (
258+ GoalAggregate aggregate ,
259+ string workItemId ,
260+ string leaseId ,
261+ GoalStatus status ,
262+ FailureState failure ,
263+ string evidenceUri )
264+ {
265+ var now = DateTimeOffset . UtcNow ;
266+ var eventPayload = JsonSerializer . Serialize ( new
267+ {
268+ failureClass = failure . FailureClass . ToString ( ) ,
269+ component = failure . Component ,
270+ message = failure . Message ,
271+ retryable = failure . Retryable ,
272+ exceptionType = failure . ExceptionType ,
273+ causeChain = failure . CauseChain ,
274+ retryAfterSeconds = failure . RetryAfterSeconds ,
275+ } ) ;
276+
277+ return aggregate with
278+ {
279+ Goal = aggregate . Goal with { Status = status } ,
280+ WorkItems = aggregate . WorkItems . Select ( item => item . Id == workItemId
281+ ? item with { Status = status == GoalStatus . Cancelled ? WorkItemStatus . Cancelled : WorkItemStatus . Failed }
282+ : item ) . ToArray ( ) ,
283+ Leases = aggregate . Leases . Where ( item => item . Id != leaseId ) . ToArray ( ) ,
284+ BudgetReservations = [ ] ,
285+ Evidence = [ .. aggregate . Evidence , new EvidenceRecord ( Guid . NewGuid ( ) . ToString ( "N" ) , aggregate . Goal . Id , workItemId , "failure-state" , evidenceUri ) ] ,
286+ Transitions = [ .. aggregate . Transitions , new ( Guid . NewGuid ( ) . ToString ( "N" ) , aggregate . Goal . Id , aggregate . Goal . Status . ToString ( ) , status . ToString ( ) , status == GoalStatus . Cancelled ? GoalStopReason . Cancellation . ToString ( ) : GoalStopReason . UnrecoverableFault . ToString ( ) , now ) ] ,
287+ Events =
288+ [
289+ .. aggregate . Events ,
290+ new GoalEventRecord ( Guid . NewGuid ( ) . ToString ( "N" ) , aggregate . Goal . Id ,
291+ aggregate . Events . Count == 0 ? 1 : aggregate . Events . Max ( item => item . Sequence ) + 1 ,
292+ "goal.failed" , eventPayload , now )
293+ ]
294+ } ;
295+ }
296+
218297 private static GoalEventRecord NewEvent ( GoalAggregate aggregate , string type , string payload ) =>
219298 new ( Guid . NewGuid ( ) . ToString ( "N" ) , aggregate . Goal . Id ,
220299 aggregate . Events . Count == 0 ? 1 : aggregate . Events . Max ( item => item . Sequence ) + 1 ,
0 commit comments