@@ -40,7 +40,7 @@ private static IWorkflowState MakeState(WorkflowState from, WorkflowState to)
4040 var state = Substitute . For < IWorkflowState > ( ) ;
4141 state . State . Returns ( from ) ;
4242 state . ExecuteAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
43- . Returns ( ci => Task . FromResult ( ci . Arg < GsdWorkflowContext > ( ) with { CurrentState = to } ) ) ;
43+ . Returns ( ci => Task . FromResult ( ci . Arg < GsdWorkflowContext > ( ) . Transition ( to ) ) ) ;
4444 return state ;
4545 }
4646
@@ -84,15 +84,17 @@ public async Task RunAsync_StateThrowsException_ContextTransitionsToFailed()
8484 }
8585
8686 [ Fact ]
87- public async Task RunAsync_NoHandlerForState_ThrowsInvalidOperationException ( )
87+ public async Task RunAsync_NoHandlerForState_ReturnsFailedContext ( )
8888 {
8989 var checkpoints = Substitute . For < ICheckpointStore > ( ) ;
9090
9191 // No states registered — Idle has no handler, throws before SaveAsync
9292 var sut = BuildSut ( checkpoints , [ ] ) ;
9393
94- await Assert . ThrowsAsync < InvalidOperationException > (
95- ( ) => sut . RunAsync ( "owner" , "repo" , 1 , triageModeOnly : false , CancellationToken . None ) ) ;
94+ var ctx = await sut . RunAsync ( "owner" , "repo" , 1 , triageModeOnly : false , CancellationToken . None ) ;
95+
96+ Assert . Equal ( WorkflowState . Failed , ctx . CurrentState ) ;
97+ Assert . Equal ( TerminalStopReason . Unknown , ctx . StopReason ) ;
9698 }
9799
98100 [ Fact ]
@@ -116,6 +118,197 @@ await checkpoints.Received(3).SaveAsync(
116118 Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) ) ;
117119 }
118120
121+ [ Fact ]
122+ public async Task RunAsync_FailedState_RecordsRollbackOriginAndReason ( )
123+ {
124+ var checkpoints = Substitute . For < ICheckpointStore > ( ) ;
125+ checkpoints . SaveAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
126+ . Returns ( Task . CompletedTask ) ;
127+
128+ var idleState = Substitute . For < IWorkflowState > ( ) ;
129+ idleState . State . Returns ( WorkflowState . Idle ) ;
130+ idleState . ExecuteAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
131+ . ThrowsAsync ( new InvalidOperationException ( "simulated failure" ) ) ;
132+
133+ var sut = BuildSut ( checkpoints , [ idleState ] ) ;
134+
135+ var ctx = await sut . RunAsync ( "owner" , "repo" , 1 , triageModeOnly : false , CancellationToken . None ) ;
136+
137+ Assert . Equal ( WorkflowState . Failed , ctx . CurrentState ) ;
138+ Assert . NotNull ( ctx . SdlcRun ) ;
139+ Assert . Equal ( "simulated failure" , ctx . SdlcRun ! . RollbackReason ) ;
140+ Assert . Equal ( "understand" , ctx . SdlcRun . RollbackOrigin ) ;
141+ Assert . Equal ( 1 , ctx . SdlcRun . NoProgressCount ) ;
142+ }
143+
144+ [ Fact ]
145+ public async Task RunAsync_AdvancesSdlcBudgets_DuringExecution ( )
146+ {
147+ var checkpoints = Substitute . For < ICheckpointStore > ( ) ;
148+ checkpoints . SaveAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
149+ . Returns ( Task . CompletedTask ) ;
150+ checkpoints . ArchiveAsync ( Arg . Any < string > ( ) , Arg . Any < CancellationToken > ( ) )
151+ . Returns ( Task . CompletedTask ) ;
152+
153+ var idleState = MakeState ( WorkflowState . Idle , WorkflowState . Done ) ;
154+ var sut = BuildSut ( checkpoints , [ idleState ] ) ;
155+
156+ var ctx = await sut . RunAsync ( "owner" , "repo" , 11 , triageModeOnly : false , CancellationToken . None ) ;
157+
158+ Assert . Equal ( WorkflowState . Done , ctx . CurrentState ) ;
159+ Assert . NotNull ( ctx . SdlcRun ) ;
160+ Assert . Equal ( 1 , ctx . SdlcRun ! . AttemptCount ) ;
161+ Assert . Equal ( 1 , ctx . SdlcRun . IterationCount ) ;
162+ Assert . Equal ( 1 , ctx . SdlcRun . ModelCallCount ) ;
163+ }
164+
165+ [ Fact ]
166+ public async Task ResumeAsync_FailedState_RequestsRollbackAndResumes ( )
167+ {
168+ var checkpoints = Substitute . For < ICheckpointStore > ( ) ;
169+ var savedCtx = new GsdWorkflowContext
170+ {
171+ WorkflowId = "rollback-wf" ,
172+ CurrentState = WorkflowState . Analyzing ,
173+ Issue = new IssueContext ( 7 , "Test issue" , "" , [ ] , "owner" , "repo" , "main" )
174+ } . WithSdlcRun ( "Rollback test" ) ;
175+ checkpoints . LoadAsync ( "rollback-wf" , Arg . Any < CancellationToken > ( ) )
176+ . Returns ( savedCtx ) ;
177+ checkpoints . SaveAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
178+ . Returns ( Task . CompletedTask ) ;
179+ checkpoints . ArchiveAsync ( Arg . Any < string > ( ) , Arg . Any < CancellationToken > ( ) )
180+ . Returns ( Task . CompletedTask ) ;
181+
182+ var analyzingState = Substitute . For < IWorkflowState > ( ) ;
183+ analyzingState . State . Returns ( WorkflowState . Analyzing ) ;
184+ analyzingState . ExecuteAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
185+ . ThrowsAsync ( new InvalidOperationException ( "verify failed" ) ) ;
186+
187+ var idleState = MakeState ( WorkflowState . Idle , WorkflowState . Done ) ;
188+ var sut = BuildSut ( checkpoints , [ idleState , analyzingState ] ) ;
189+
190+ var ctx = await sut . ResumeAsync ( "rollback-wf" , CancellationToken . None ) ;
191+
192+ Assert . Equal ( WorkflowState . Done , ctx . CurrentState ) ;
193+ Assert . NotNull ( ctx . SdlcRun ) ;
194+ Assert . Equal ( "understand" , ctx . SdlcRun ! . RollbackOrigin ) ;
195+ Assert . Equal ( "verify failed" , ctx . SdlcRun . RollbackReason ) ;
196+ Assert . Null ( ctx . PendingRollback ) ;
197+ }
198+
199+ [ Fact ]
200+ public async Task RunAsync_DoneState_RecordsMemoryCandidateAndTerminalOutcome ( )
201+ {
202+ var checkpoints = Substitute . For < ICheckpointStore > ( ) ;
203+ checkpoints . SaveAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
204+ . Returns ( Task . CompletedTask ) ;
205+ checkpoints . ArchiveAsync ( Arg . Any < string > ( ) , Arg . Any < CancellationToken > ( ) )
206+ . Returns ( Task . CompletedTask ) ;
207+
208+ var idleState = MakeState ( WorkflowState . Idle , WorkflowState . Done ) ;
209+ var sut = BuildSut ( checkpoints , [ idleState ] ) ;
210+
211+ var ctx = await sut . RunAsync ( "owner" , "repo" , 12 , triageModeOnly : false , CancellationToken . None ) ;
212+
213+ Assert . Equal ( WorkflowState . Done , ctx . CurrentState ) ;
214+ Assert . NotNull ( ctx . SdlcRun ) ;
215+ Assert . Equal ( "passed" , ctx . SdlcRun ! . TerminalOutcome ) ;
216+ Assert . NotNull ( ctx . SdlcRun . MemoryCandidateRecords ) ;
217+ Assert . NotEmpty ( ctx . SdlcRun . MemoryCandidateRecords ! ) ;
218+ Assert . Equal ( "update-memory" , ctx . SdlcRun . MemoryCandidateRecords ! [ 0 ] . PhaseId ) ;
219+ }
220+
221+ [ Fact ]
222+ public async Task RunAsync_GoalAttemptBudgetExceeded_SetsTypedStopReason ( )
223+ {
224+ var checkpoints = Substitute . For < ICheckpointStore > ( ) ;
225+ var savedCtx = new GsdWorkflowContext
226+ {
227+ WorkflowId = "wf-budget" ,
228+ CurrentState = WorkflowState . Idle ,
229+ Issue = new IssueContext ( 1 , "Budget" , "" , [ ] , "owner" , "repo" , "main" ) ,
230+ SdlcRun = SdlcRunRecord . Create ( "wf-budget" , "Budget" , SdlcProfile . CasSdlcV1 ) with
231+ {
232+ AttemptCount = SdlcProfile . CasSdlcV1 . GoalAttempts + 1
233+ }
234+ } ;
235+ checkpoints . LoadAsync ( "wf-budget" , Arg . Any < CancellationToken > ( ) )
236+ . Returns ( savedCtx ) ;
237+ checkpoints . SaveAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
238+ . Returns ( Task . CompletedTask ) ;
239+
240+ var idleState = Substitute . For < IWorkflowState > ( ) ;
241+ idleState . State . Returns ( WorkflowState . Idle ) ;
242+ idleState . ExecuteAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
243+ . Returns ( ci => Task . FromResult ( ci . Arg < GsdWorkflowContext > ( ) . Transition ( WorkflowState . Done ) ) ) ;
244+
245+ var sut = BuildSut ( checkpoints , [ idleState ] ) ;
246+
247+ var ctx = await sut . ResumeAsync ( "wf-budget" , CancellationToken . None ) ;
248+
249+ Assert . Equal ( WorkflowState . Failed , ctx . CurrentState ) ;
250+ Assert . Equal ( TerminalStopReason . BudgetExhausted , ctx . StopReason ) ;
251+ }
252+
253+ [ Fact ]
254+ public async Task RunAsync_RuntimeMinuteBudgetExceeded_SetsTypedStopReason ( )
255+ {
256+ var checkpoints = Substitute . For < ICheckpointStore > ( ) ;
257+ var savedCtx = new GsdWorkflowContext
258+ {
259+ WorkflowId = "wf-runtime" ,
260+ CurrentState = WorkflowState . Idle ,
261+ Issue = new IssueContext ( 1 , "Runtime" , "" , [ ] , "owner" , "repo" , "main" ) ,
262+ SdlcRun = SdlcRunRecord . Create ( "wf-runtime" , "Runtime" , SdlcProfile . CasSdlcV1 ) with
263+ {
264+ StartedAt = DateTimeOffset . UtcNow . AddMinutes ( - ( SdlcProfile . CasSdlcV1 . RuntimeMinutes + 1 ) )
265+ }
266+ } ;
267+ checkpoints . LoadAsync ( "wf-runtime" , Arg . Any < CancellationToken > ( ) )
268+ . Returns ( savedCtx ) ;
269+ checkpoints . SaveAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
270+ . Returns ( Task . CompletedTask ) ;
271+
272+ var idleState = Substitute . For < IWorkflowState > ( ) ;
273+ idleState . State . Returns ( WorkflowState . Idle ) ;
274+ idleState . ExecuteAsync ( Arg . Any < GsdWorkflowContext > ( ) , Arg . Any < CancellationToken > ( ) )
275+ . Returns ( ci => Task . FromResult ( ci . Arg < GsdWorkflowContext > ( ) . Transition ( WorkflowState . Done ) ) ) ;
276+
277+ var sut = BuildSut ( checkpoints , [ idleState ] ) ;
278+
279+ var ctx = await sut . ResumeAsync ( "wf-runtime" , CancellationToken . None ) ;
280+
281+ Assert . Equal ( WorkflowState . Failed , ctx . CurrentState ) ;
282+ Assert . Equal ( TerminalStopReason . RuntimeExceeded , ctx . StopReason ) ;
283+ }
284+
285+ [ Fact ]
286+ public void RecordTerminalOutcome_IsIdempotent ( )
287+ {
288+ var run = SdlcRunRecord . Create ( "wf-terminal" , "Terminal" , SdlcProfile . CasSdlcV1 ) ;
289+
290+ var once = run . RecordTerminalOutcome ( "passed" ) ;
291+ var twice = once . RecordTerminalOutcome ( "failed" ) ;
292+
293+ Assert . Equal ( "passed" , once . TerminalOutcome ) ;
294+ Assert . Equal ( "passed" , twice . TerminalOutcome ) ;
295+ }
296+
297+ [ Fact ]
298+ public void RecordVerification_PersistsInvalidatedPhases ( )
299+ {
300+ var run = SdlcRunRecord . Create ( "wf-invalidated" , "Invalidated" , SdlcProfile . CasSdlcV1 ) ;
301+ var updated = run . RecordVerification ( new SdlcVerificationRecord (
302+ "research" ,
303+ "repo-verifier" ,
304+ Passed : false ,
305+ InvalidatedPhaseIds : [ "understand" , "research" ] ,
306+ Reason : "missing evidence" ) ) ;
307+
308+ Assert . Equal ( [ "understand" , "research" ] , updated . InvalidatedPhaseIds ) ;
309+ Assert . Single ( updated . Verifications ! ) ;
310+ }
311+
119312 [ Fact ]
120313 public async Task ResumeAsync_CheckpointExists_ResumesFromSavedState ( )
121314 {
@@ -175,4 +368,5 @@ public async Task RunAsync_CancellationRequested_ThrowsOperationCanceledExceptio
175368 await Assert . ThrowsAsync < OperationCanceledException > (
176369 ( ) => sut . RunAsync ( "owner" , "repo" , 1 , triageModeOnly : false , cts . Token ) ) ;
177370 }
371+
178372}
0 commit comments