Skip to content

Commit 439e75d

Browse files
OgeonX-AiAitomatesclaude
authored
feat(27-02): map loop failures to typed FailureState records (#21)
Loop boundary failures now produce typed FailureState evidence (CAS Contracts schema) instead of escaping uncaught. Includes FailureClassifier wiring in LoopCoordinator and unit coverage. Rescued 2026-07-08: implementation was verified in the 27-02 session but never committed (caught by the Phase 35 audit's live spot-verification). Co-authored-by: Kim Harjamäki <kim.harjamaki@prosimo.fi> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2080c44 commit 439e75d

5 files changed

Lines changed: 270 additions & 40 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using GsdOrchestrator.Loop;
2+
using GsdOrchestrator.Scheduling;
3+
using Xunit;
4+
5+
namespace GsdOrchestrator.Tests;
6+
7+
public sealed class FailureStateTests
8+
{
9+
[Fact]
10+
public void Timeout_Is_Classified_As_Transient_And_Retryable()
11+
{
12+
var result = FailureClassifier.Classify(new TimeoutException("timed out"), "component");
13+
14+
Assert.Equal(FailureClass.Transient, result.FailureClass);
15+
Assert.True(result.Retryable);
16+
Assert.Equal(5d, result.RetryAfterSeconds);
17+
}
18+
19+
[Fact]
20+
public void OperationCanceled_Is_Classified_As_Cancellation()
21+
{
22+
var result = FailureClassifier.Classify(new OperationCanceledException("cancelled"), "component");
23+
24+
Assert.Equal(FailureClass.Cancellation, result.FailureClass);
25+
Assert.False(result.Retryable);
26+
Assert.Null(result.RetryAfterSeconds);
27+
}
28+
29+
[Fact]
30+
public void InvalidOperation_Is_Classified_As_Deterministic()
31+
{
32+
var result = FailureClassifier.Classify(new InvalidOperationException("Goal 'x' was not found."), "component");
33+
34+
Assert.Equal(FailureClass.Deterministic, result.FailureClass);
35+
Assert.False(result.Retryable);
36+
}
37+
38+
[Fact]
39+
public void UnauthorizedAccess_Is_Classified_As_Policy()
40+
{
41+
var result = FailureClassifier.Classify(new UnauthorizedAccessException("denied"), "component");
42+
43+
Assert.Equal(FailureClass.Policy, result.FailureClass);
44+
Assert.False(result.Retryable);
45+
}
46+
47+
[Fact]
48+
public void Unknown_Exception_Falls_Back_To_Unrecoverable()
49+
{
50+
var result = FailureClassifier.Classify(new UnknownFailureException("boom"), "component");
51+
52+
Assert.Equal(FailureClass.Unrecoverable, result.FailureClass);
53+
Assert.False(result.Retryable);
54+
}
55+
56+
[Fact]
57+
public void Cause_Chain_Is_Collected_Outermost_First()
58+
{
59+
var exception = new InvalidOperationException("outer", new TimeoutException("inner"));
60+
61+
var result = FailureClassifier.Classify(exception, "component");
62+
63+
Assert.Equal(["outer", "inner"], result.CauseChain);
64+
}
65+
66+
private sealed class UnknownFailureException(string message) : Exception(message);
67+
}

src/GsdOrchestrator.Tests/LoopCoordinatorTests.cs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@ public async Task RunAsync_CreatesBoundedRepairAndReverifies()
3838
Assert.Single(fixture.Learning.Outcomes);
3939
}
4040

41+
[Fact]
42+
public async Task RunAsync_Converts_Worker_Exception_Into_Typed_Failure_State()
43+
{
44+
await using var fixture = await Fixture.CreateAsync([Passed()], worker: new ThrowingWorker(new TimeoutException("worker timeout")));
45+
46+
var result = await fixture.Coordinator.RunAsync("goal-1", Budget());
47+
48+
Assert.Equal(GoalStatus.Failed, result.Aggregate.Goal.Status);
49+
Assert.Contains(result.Aggregate.Evidence, item => item.Kind == "failure-state");
50+
var failureEvent = Assert.Single(result.Aggregate.Events, item => item.Type == "goal.failed");
51+
Assert.Contains("Transient", failureEvent.PayloadJson);
52+
Assert.Contains("worker timeout", failureEvent.PayloadJson);
53+
Assert.Single(fixture.Learning.Outcomes);
54+
Assert.Contains("worker timeout", fixture.Learning.Outcomes[0].Summary);
55+
Assert.Equal(GoalStatus.Failed, fixture.Learning.Outcomes[0].Status);
56+
}
57+
4158
[Fact]
4259
public void PolicyGuard_DeniesEnvironmentAndHoldsExternalActions()
4360
{
@@ -60,14 +77,14 @@ private sealed class Fixture : IAsyncDisposable
6077

6178
private Fixture(string path, LoopCoordinator coordinator, CapturingLearning learning) => (_path, Coordinator, Learning) = (path, coordinator, learning);
6279

63-
public static async Task<Fixture> CreateAsync(IReadOnlyList<VerificationRunResult> results)
80+
public static async Task<Fixture> CreateAsync(IReadOnlyList<VerificationRunResult> results, ILoopWorker? worker = null)
6481
{
6582
var path = Path.Combine(Path.GetTempPath(), $"loop-{Guid.NewGuid():N}.db");
6683
var store = new SqliteGoalStore(path, NullLogger<SqliteGoalStore>.Instance);
6784
await store.InitializeAsync();
6885
await store.SaveAsync(Aggregate());
6986
var learning = new CapturingLearning();
70-
return new(path, new(store, new SuccessfulWorker(), new ScriptedVerifier(results), learning), learning);
87+
return new(path, new(store, worker ?? new SuccessfulWorker(), new ScriptedVerifier(results), learning, NullLogger<LoopCoordinator>.Instance), learning);
7188
}
7289

7390
public ValueTask DisposeAsync()
@@ -88,6 +105,12 @@ public Task<LoopWorkResult> ExecuteAsync(LoopWorkRequest request, CancellationTo
88105
Task.FromResult(new LoopWorkResult(true, [$"cas://evidence/worker/{request.Attempt}"], request.IsRepair ? "repair" : "feature"));
89106
}
90107

108+
private sealed class ThrowingWorker(Exception exception) : ILoopWorker
109+
{
110+
public Task<LoopWorkResult> ExecuteAsync(LoopWorkRequest request, CancellationToken cancellationToken) =>
111+
Task.FromException<LoopWorkResult>(exception);
112+
}
113+
91114
private sealed class ScriptedVerifier(IReadOnlyList<VerificationRunResult> results) : ILoopVerifier
92115
{
93116
private int _index;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System.Net.Http;
2+
using System.Net.Sockets;
3+
using GsdOrchestrator.Scheduling;
4+
5+
namespace GsdOrchestrator.Loop;
6+
7+
public sealed record FailureState(
8+
FailureClass FailureClass,
9+
string Component,
10+
string Message,
11+
bool Retryable,
12+
string? ExceptionType = null,
13+
IReadOnlyList<string>? CauseChain = null,
14+
double? RetryAfterSeconds = null);
15+
16+
public static class FailureClassifier
17+
{
18+
private const int MaxMessageLength = 1024;
19+
private const int MaxCauseChainEntries = 10;
20+
21+
public static FailureState Classify(Exception exception, string component)
22+
{
23+
var (failureClass, retryable, retryAfterSeconds) = exception switch
24+
{
25+
OperationCanceledException => (FailureClass.Cancellation, false, (double?)null),
26+
TimeoutException => (FailureClass.Transient, true, 5d),
27+
HttpRequestException => (FailureClass.Transient, true, 5d),
28+
SocketException => (FailureClass.Transient, true, 5d),
29+
UnauthorizedAccessException => (FailureClass.Policy, false, (double?)null),
30+
ArgumentOutOfRangeException => (FailureClass.Policy, false, (double?)null),
31+
InvalidOperationException => (FailureClass.Deterministic, false, (double?)null),
32+
ArgumentException => (FailureClass.Deterministic, false, (double?)null),
33+
_ => (FailureClass.Unrecoverable, false, (double?)null),
34+
};
35+
36+
return new FailureState(
37+
failureClass,
38+
component,
39+
Truncate(exception.Message),
40+
retryable,
41+
exception.GetType().FullName,
42+
BuildCauseChain(exception),
43+
retryAfterSeconds);
44+
}
45+
46+
private static string Truncate(string message) =>
47+
message.Length <= MaxMessageLength ? message : message[..MaxMessageLength];
48+
49+
private static IReadOnlyList<string>? BuildCauseChain(Exception exception)
50+
{
51+
var chain = new List<string>(capacity: 4);
52+
var current = exception;
53+
while (current is not null && chain.Count < MaxCauseChainEntries)
54+
{
55+
chain.Add(Truncate(current.Message));
56+
current = current.InnerException;
57+
}
58+
59+
return chain.Count == 0 ? null : chain;
60+
}
61+
}

src/GsdOrchestrator/Loop/LoopCoordinator.cs

Lines changed: 116 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using GsdOrchestrator.Mcp;
55
using GsdOrchestrator.Scheduling;
66
using GsdOrchestrator.Verification;
7+
using Microsoft.Extensions.Logging;
78

89
namespace 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,

tools/LoopPilotRunner/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ async Task<PilotEvidence> RunCoordinatorScenario(string scenario, IReadOnlyList<
3636
await store.SaveAsync(Seed($"goal-{scenario}"));
3737
var learning = new CapturingLearning();
3838
var worker = new MafProcessLoopWorker(arguments.Python, arguments.MafRoot);
39-
var coordinator = new LoopCoordinator(store, worker, new ScriptedVerifier(results), learning);
39+
var coordinator = new LoopCoordinator(store, worker, new ScriptedVerifier(results), learning, NullLogger<LoopCoordinator>.Instance);
4040
var run = await coordinator.RunAsync($"goal-{scenario}", new(true, 0, 2, 0, 2, 0, 10));
4141
var peak = run.PeakConcurrency;
4242
var isolatedImplementation = scenario != "feature" || VerifyIsolatedWorktree();

0 commit comments

Comments
 (0)