Skip to content

Commit 3b050d6

Browse files
committed
chore(sync): resolve merge conflicts
2 parents a194a6f + 7cbfde9 commit 3b050d6

33 files changed

Lines changed: 2145 additions & 82 deletions

GithubMCP.slnx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@
33
<Project Path="src/GsdOrchestrator/GsdOrchestrator.csproj" />
44
<Project Path="src/GsdOrchestrator.Tests/GsdOrchestrator.Tests.csproj" />
55
</Folder>
6+
<Folder Name="/tools/">
7+
<Project Path="tools/LoopPilotRunner/LoopPilotRunner.csproj" />
8+
</Folder>
69
</Solution>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using GsdOrchestrator.Workflows.Services;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using Xunit;
4+
5+
namespace GsdOrchestrator.Tests;
6+
7+
public sealed class FileWatchStateStoreTests : IDisposable
8+
{
9+
private readonly string _root = Path.Combine(Path.GetTempPath(), $"watch-store-{Guid.NewGuid():N}");
10+
11+
[Fact]
12+
public async Task MarkProcessedAsync_SurvivesStoreRecreation()
13+
{
14+
var first = new FileWatchStateStore(_root, NullLogger<FileWatchStateStore>.Instance);
15+
await first.MarkProcessedAsync("org", "repo", "issue-42", CancellationToken.None);
16+
17+
var second = new FileWatchStateStore(_root, NullLogger<FileWatchStateStore>.Instance);
18+
19+
Assert.True(await second.IsProcessedAsync("org", "repo", "issue-42", CancellationToken.None));
20+
}
21+
22+
[Fact]
23+
public async Task MarkProcessedAsync_SanitizesPathAndLeavesNoTemporaryFile()
24+
{
25+
var sut = new FileWatchStateStore(_root, NullLogger<FileWatchStateStore>.Instance);
26+
27+
await sut.MarkProcessedAsync("../org", "repo/../../escape", "issue:7", CancellationToken.None);
28+
29+
var files = Directory.GetFiles(Path.Combine(_root, ".gsd", "watch"));
30+
Assert.Single(files);
31+
Assert.EndsWith(".done", files[0]);
32+
Assert.DoesNotContain("..", Path.GetFileName(files[0]));
33+
Assert.Empty(Directory.GetFiles(Path.Combine(_root, ".gsd", "watch"), "*.tmp"));
34+
}
35+
36+
public void Dispose()
37+
{
38+
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
39+
}
40+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using GsdOrchestrator.Scheduling;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using Xunit;
4+
5+
namespace GsdOrchestrator.Tests;
6+
7+
public sealed class GoalControlPlaneTests : IDisposable
8+
{
9+
private readonly string _root = Path.Combine(Path.GetTempPath(), $"control-plane-{Guid.NewGuid():N}");
10+
11+
[Fact]
12+
public async Task StartInspectCancel_PersistsExplicitLifecycle()
13+
{
14+
var (store, sut) = await Create();
15+
var aggregate = GoalFixtures.CompleteAggregate() with
16+
{
17+
Goal = GoalFixtures.CompleteAggregate().Goal with { Status = GoalStatus.Planned },
18+
Transitions = [], Events = [], Evidence = [], Leases = [], BudgetReservations = [], Attempts = [], IdempotencyKeys = []
19+
};
20+
21+
await sut.StartAsync(aggregate);
22+
Assert.Equal(GoalStatus.Running, (await sut.InspectAsync("goal-1"))!.Goal.Status);
23+
24+
await sut.CancelAsync("goal-1", "operator request", "cas://evidence/cancel");
25+
var cancelled = await store.LoadAsync("goal-1");
26+
Assert.Equal(GoalStatus.Cancelled, cancelled!.Goal.Status);
27+
Assert.Contains(cancelled.Transitions, transition => transition.To == nameof(GoalStatus.Cancelled));
28+
Assert.Contains(cancelled.Evidence, evidence => evidence.Uri == "cas://evidence/cancel");
29+
}
30+
31+
[Fact]
32+
public async Task Resume_PausedGoal_ReturnsToRunningWithoutLosingEvidence()
33+
{
34+
var (store, sut) = await Create();
35+
var aggregate = GoalFixtures.CompleteAggregate() with
36+
{
37+
Goal = GoalFixtures.CompleteAggregate().Goal with { Status = GoalStatus.Paused }
38+
};
39+
await store.SaveAsync(aggregate);
40+
41+
await sut.ResumeAsync("goal-1", "operator resume");
42+
var resumed = await sut.InspectAsync("goal-1");
43+
Assert.Equal(GoalStatus.Running, resumed!.Goal.Status);
44+
Assert.Equal(aggregate.Evidence.Count, resumed.Evidence.Count);
45+
}
46+
47+
private async Task<(SqliteGoalStore Store, GoalControlPlane Sut)> Create()
48+
{
49+
var store = new SqliteGoalStore(Path.Combine(_root, "goals.db"), NullLogger<SqliteGoalStore>.Instance);
50+
await store.InitializeAsync();
51+
return (store, new GoalControlPlane(store, NullLogger<GoalControlPlane>.Instance));
52+
}
53+
54+
public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, true); }
55+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using GsdOrchestrator.Scheduling;
2+
using Xunit;
3+
4+
namespace GsdOrchestrator.Tests;
5+
6+
public class GoalDecisionPolicyTests
7+
{
8+
[Theory]
9+
[InlineData(FailureClass.Transient, DecisionAction.Retry)]
10+
[InlineData(FailureClass.Deterministic, DecisionAction.CreateRepair)]
11+
[InlineData(FailureClass.Policy, DecisionAction.Stop)]
12+
[InlineData(FailureClass.Cancellation, DecisionAction.Stop)]
13+
[InlineData(FailureClass.Unrecoverable, DecisionAction.Stop)]
14+
public void Decide_ClassifiesEveryFailureWithBudgetAndEvidence(FailureClass failure, DecisionAction action)
15+
{
16+
var result = GoalDecisionPolicy.DecideFailure(failure, consumedAttempts: 1, maxAttempts: 3, noProgressCount: 0, noProgressLimit: 2, ["evidence-1"]);
17+
Assert.Equal(action, result.Action);
18+
Assert.Equal(1, result.ConsumedAttempts);
19+
Assert.NotEmpty(result.Reason);
20+
Assert.Equal(["evidence-1"], result.EvidenceIds);
21+
}
22+
23+
[Fact]
24+
public void Decide_ExhaustedAttempts_StopsWithExhaustion()
25+
{
26+
var result = GoalDecisionPolicy.DecideFailure(FailureClass.Transient, 3, 3, 0, 2, ["e"]);
27+
Assert.Equal(GoalStopReason.Exhaustion, result.StopReason);
28+
}
29+
30+
[Fact]
31+
public void Decide_NoProgressLimit_StopsBeforeRetry()
32+
{
33+
var result = GoalDecisionPolicy.DecideFailure(FailureClass.Deterministic, 1, 3, 2, 2, ["e"]);
34+
Assert.Equal(GoalStopReason.NoProgress, result.StopReason);
35+
}
36+
37+
[Theory]
38+
[InlineData(GoalStopReason.Passed)]
39+
[InlineData(GoalStopReason.Exhaustion)]
40+
[InlineData(GoalStopReason.Cancellation)]
41+
[InlineData(GoalStopReason.Denial)]
42+
[InlineData(GoalStopReason.ApprovalWait)]
43+
[InlineData(GoalStopReason.Deadlock)]
44+
[InlineData(GoalStopReason.UnrecoverableFault)]
45+
[InlineData(GoalStopReason.NoProgress)]
46+
public void Stop_ExposesEveryDistinctReasonWithEvidence(GoalStopReason reason)
47+
{
48+
var result = GoalDecisionPolicy.Stop(reason, "explicit outcome", ["evidence-1"]);
49+
Assert.Equal(reason, result.StopReason);
50+
Assert.Equal(DecisionAction.Stop, result.Action);
51+
Assert.NotEmpty(result.EvidenceIds);
52+
}
53+
54+
[Fact]
55+
public void Stop_WithoutEvidence_IsRejected()
56+
{
57+
Assert.Throws<ArgumentException>(() => GoalDecisionPolicy.Stop(GoalStopReason.Deadlock, "deadlock", []));
58+
}
59+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using GsdOrchestrator.Scheduling;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
using Xunit;
4+
5+
namespace GsdOrchestrator.Tests;
6+
7+
public sealed class GoalSchedulerTests : IDisposable
8+
{
9+
private readonly string _root = Path.Combine(Path.GetTempPath(), $"scheduler-{Guid.NewGuid():N}");
10+
11+
[Fact]
12+
public async Task TryAcquireLease_IncompleteDependency_IsDenied()
13+
{
14+
var store = await CreateStore();
15+
var aggregate = AggregateWithTwoItems(WorkItemStatus.Pending);
16+
await store.SaveAsync(aggregate);
17+
var scheduler = new GoalScheduler(store, NullLogger<GoalScheduler>.Instance);
18+
19+
var lease = await scheduler.TryAcquireLeaseAsync(Request("work-2"));
20+
21+
Assert.Null(lease);
22+
}
23+
24+
[Fact]
25+
public async Task TryAcquireLease_CompetingCalls_RespectsGlobalLimitAtomically()
26+
{
27+
var store = await CreateStore();
28+
await store.SaveAsync(IndependentAggregate());
29+
var scheduler = new GoalScheduler(store, NullLogger<GoalScheduler>.Instance);
30+
31+
var results = await Task.WhenAll(
32+
scheduler.TryAcquireLeaseAsync(Request("work-1", global: 1)),
33+
scheduler.TryAcquireLeaseAsync(Request("work-2", global: 1)));
34+
35+
Assert.Single(results, result => result is not null);
36+
}
37+
38+
[Theory]
39+
[InlineData(10, 1, 10)]
40+
[InlineData(10, 10, 1)]
41+
public async Task TryAcquireLease_ProviderOrRepositoryLimit_DeniesSecond(int global, int provider, int repository)
42+
{
43+
var store = await CreateStore();
44+
await store.SaveAsync(IndependentAggregate());
45+
var scheduler = new GoalScheduler(store, NullLogger<GoalScheduler>.Instance);
46+
Assert.NotNull(await scheduler.TryAcquireLeaseAsync(Request("work-1", global, provider, repository)));
47+
Assert.Null(await scheduler.TryAcquireLeaseAsync(Request("work-2", global, provider, repository)));
48+
}
49+
50+
[Fact]
51+
public async Task RecoverExpiredLeases_AllowsReacquire()
52+
{
53+
var store = await CreateStore();
54+
await store.SaveAsync(IndependentAggregate());
55+
var scheduler = new GoalScheduler(store, NullLogger<GoalScheduler>.Instance);
56+
var now = DateTimeOffset.UtcNow;
57+
Assert.NotNull(await scheduler.TryAcquireLeaseAsync(
58+
new LeaseRequest("goal-1", "work-1", "worker-1", now.AddMinutes(-2), now.AddMinutes(-1), 3, 3, 3)));
59+
60+
Assert.Equal(1, await scheduler.RecoverExpiredLeasesAsync(now));
61+
Assert.NotNull(await scheduler.TryAcquireLeaseAsync(Request("work-1")));
62+
}
63+
64+
[Fact]
65+
public async Task ReserveIdempotencyKey_RestartRejectsDuplicateEffect()
66+
{
67+
var path = Path.Combine(_root, "goals.db");
68+
var first = new SqliteGoalStore(path, NullLogger<SqliteGoalStore>.Instance);
69+
await first.InitializeAsync();
70+
await first.SaveAsync(IndependentAggregate());
71+
Assert.True(await first.TryReserveIdempotencyKeyAsync("goal-1", "work-1", "commit:abc", "commit"));
72+
73+
var restarted = new SqliteGoalStore(path, NullLogger<SqliteGoalStore>.Instance);
74+
await restarted.InitializeAsync();
75+
Assert.False(await restarted.TryReserveIdempotencyKeyAsync("goal-1", "work-1", "commit:abc", "commit"));
76+
}
77+
78+
private LeaseRequest Request(string workItemId, int global = 3, int provider = 3, int repository = 3, DateTimeOffset? expiresAt = null) =>
79+
new("goal-1", workItemId, "worker-1", DateTimeOffset.UtcNow, expiresAt ?? DateTimeOffset.UtcNow.AddMinutes(5), global, provider, repository);
80+
81+
private async Task<SqliteGoalStore> CreateStore()
82+
{
83+
var store = new SqliteGoalStore(Path.Combine(_root, "goals.db"), NullLogger<SqliteGoalStore>.Instance);
84+
await store.InitializeAsync();
85+
return store;
86+
}
87+
88+
private static GoalAggregate AggregateWithTwoItems(WorkItemStatus dependencyStatus)
89+
{
90+
var seed = GoalFixtures.CompleteAggregate();
91+
return seed with
92+
{
93+
WorkItems =
94+
[
95+
new WorkItemRecord("work-1", "goal-1", "org/repo", "local", dependencyStatus, 3, "idem-1"),
96+
new WorkItemRecord("work-2", "goal-1", "org/repo", "local", WorkItemStatus.Ready, 3, "idem-2")
97+
],
98+
Dependencies = [new DependencyRecord("goal-1", "work-2", "work-1")],
99+
Attempts = [], Leases = [], BudgetReservations = [], IdempotencyKeys = []
100+
};
101+
}
102+
103+
private static GoalAggregate IndependentAggregate()
104+
{
105+
var seed = GoalFixtures.CompleteAggregate();
106+
return seed with
107+
{
108+
WorkItems =
109+
[
110+
new WorkItemRecord("work-1", "goal-1", "org/repo", "local", WorkItemStatus.Ready, 3, "idem-1"),
111+
new WorkItemRecord("work-2", "goal-1", "org/repo", "local", WorkItemStatus.Ready, 3, "idem-2")
112+
],
113+
Dependencies = [], Attempts = [], Leases = [], BudgetReservations = [], IdempotencyKeys = []
114+
};
115+
}
116+
117+
public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, true); }
118+
}

src/GsdOrchestrator.Tests/GsdStateMachineTests.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,80 @@ public async Task ResumeAsync_CheckpointExists_ResumesFromSavedState()
334334
Assert.Equal(WorkflowState.Done, ctx.CurrentState);
335335
}
336336

337+
[Fact]
338+
public async Task ResumeAsync_RecoverableFailure_ReentersFailedStateWithoutReplayingHistory()
339+
{
340+
var checkpoints = Substitute.For<ICheckpointStore>();
341+
var priorTransition = new StateTransitionEvent(
342+
WorkflowState.Idle,
343+
WorkflowState.Analyzing,
344+
DateTimeOffset.UtcNow.AddMinutes(-1));
345+
var savedCtx = new GsdWorkflowContext
346+
{
347+
WorkflowId = "recoverable-wf",
348+
CurrentState = WorkflowState.Failed,
349+
FailedState = WorkflowState.Analyzing,
350+
RetryCount = 0,
351+
FailureReason = "transient failure",
352+
Issue = new IssueContext(7, "Test issue", "", [], "owner", "repo", "main"),
353+
History = [priorTransition]
354+
};
355+
checkpoints.LoadAsync(savedCtx.WorkflowId, Arg.Any<CancellationToken>()).Returns(savedCtx);
356+
checkpoints.SaveAsync(Arg.Any<GsdWorkflowContext>(), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
357+
checkpoints.ArchiveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
358+
359+
var analyzing = MakeState(WorkflowState.Analyzing, WorkflowState.Done);
360+
var sut = BuildSut(checkpoints, [analyzing]);
361+
362+
var result = await sut.ResumeAsync(savedCtx.WorkflowId, CancellationToken.None);
363+
364+
Assert.Equal(WorkflowState.Done, result.CurrentState);
365+
Assert.Contains(result.History, transition =>
366+
transition.From == WorkflowState.Failed && transition.To == WorkflowState.Analyzing);
367+
Assert.Contains(priorTransition, result.History);
368+
await analyzing.Received(1).ExecuteAsync(
369+
Arg.Is<GsdWorkflowContext>(context => context.CurrentState == WorkflowState.Analyzing && context.RetryCount == 1),
370+
Arg.Any<CancellationToken>());
371+
}
372+
373+
[Fact]
374+
public async Task ResumeAsync_RetryExhausted_ThrowsClearError()
375+
{
376+
var checkpoints = Substitute.For<ICheckpointStore>();
377+
var savedCtx = new GsdWorkflowContext
378+
{
379+
WorkflowId = "exhausted-wf",
380+
CurrentState = WorkflowState.Failed,
381+
FailedState = WorkflowState.Analyzing,
382+
RetryCount = 1
383+
};
384+
checkpoints.LoadAsync(savedCtx.WorkflowId, Arg.Any<CancellationToken>()).Returns(savedCtx);
385+
var sut = BuildSut(checkpoints, []);
386+
387+
var error = await Assert.ThrowsAsync<InvalidOperationException>(
388+
() => sut.ResumeAsync(savedCtx.WorkflowId, CancellationToken.None));
389+
390+
Assert.Contains("retry limit", error.Message, StringComparison.OrdinalIgnoreCase);
391+
}
392+
393+
[Fact]
394+
public async Task ResumeAsync_LegacyFailedCheckpoint_ThrowsClearError()
395+
{
396+
var checkpoints = Substitute.For<ICheckpointStore>();
397+
var savedCtx = new GsdWorkflowContext
398+
{
399+
WorkflowId = "legacy-failed-wf",
400+
CurrentState = WorkflowState.Failed
401+
};
402+
checkpoints.LoadAsync(savedCtx.WorkflowId, Arg.Any<CancellationToken>()).Returns(savedCtx);
403+
var sut = BuildSut(checkpoints, []);
404+
405+
var error = await Assert.ThrowsAsync<InvalidOperationException>(
406+
() => sut.ResumeAsync(savedCtx.WorkflowId, CancellationToken.None));
407+
408+
Assert.Contains("recoverable state", error.Message, StringComparison.OrdinalIgnoreCase);
409+
}
410+
337411
[Fact]
338412
public async Task ResumeAsync_NoCheckpointExists_ThrowsInvalidOperationException()
339413
{

0 commit comments

Comments
 (0)