diff --git a/docs/plans/autonomy-levels.md b/docs/plans/autonomy-levels.md index bd800e7..2856a3c 100644 --- a/docs/plans/autonomy-levels.md +++ b/docs/plans/autonomy-levels.md @@ -40,6 +40,11 @@ others learned. The board shows each ticket's phase (Discovery → Coding → Te `docs/ACCEPTANCE.md`, run the unit tests, bring the stack up and run the Playwright specs, run data checks (e.g. the CBL corpus), and post the evidence on the PR. Pass → approve; fail → *Ask for changes* with the findings. +3c. ✅ (#270) **One ticket = one MAF workflow.** The Autopilot Orchestrator agent decides which ticket starts; a + Microsoft Agent Framework Workflow (`TicketWorkflow`) then runs it — Coding → checks → Reviewer → Testing → merge — + with conditional edges back to Coding (red checks, conflicts, requested changes, failed acceptance; 3 rounds). A + flow that ends without a merge, or one a restart cut short, is picked up step by step. Settings › Autopilot › *Each + ticket runs as* (MAF workflow / Step by step). 3b. ✅ (#264) **Reviewer agent.** Between green CI and acceptance testing: a read-only agent reviews the diff in context and posts its review on the PR; must-fix findings go back to the Coding agent (same 3 bounded rounds). 4. ✅ (#242, *Build and merge*: on green CI; acceptance agent next) **Merge policy.** Per project: merge on green + acceptance pass (squash, delete branch), or wait for a person. diff --git a/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs b/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs index 4426c2e..d08ed17 100644 --- a/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs +++ b/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs @@ -18,9 +18,27 @@ public sealed class AutopilotSettings : ISettingsContributor /// The fixed rules decide. public const string Rules = "Rules"; + /// How each started ticket runs. + public const string TicketEngineKey = "Autopilot:TicketEngine"; + + /// A Microsoft Agent Framework workflow runs the ticket's whole agent flow. + public const string MafWorkflow = "MAF workflow"; + + /// Autopilot drives each step itself, pass by pass. + public const string StepByStep = "Step by step"; + + /// True when the stored value means a MAF workflow (anything but "Step by step"). + public static bool UsesMafWorkflow(string? value) => !string.Equals(value?.Trim(), StepByStep, System.StringComparison.OrdinalIgnoreCase); + /// public IEnumerable Describe() { + yield return new(TicketEngineKey, SettingKind.Choice, "Autopilot", "Each ticket runs as") + { + DefaultValue = MafWorkflow, + Choices = [MafWorkflow, StepByStep], + Help = "MAF workflow: a Microsoft Agent Framework graph runs Coding → checks → Reviewer → Testing → merge for the ticket, routing every verdict. Step by step: Autopilot drives each step itself every minute. Both use the same agents, rules and evidence.", + }; yield return new(DecidedByKey, SettingKind.Choice, "Autopilot", "Decided by") { DefaultValue = Agent, diff --git a/src/AgentOs.Web/Program.cs b/src/AgentOs.Web/Program.cs index 109b7ca..523fe7a 100644 --- a/src/AgentOs.Web/Program.cs +++ b/src/AgentOs.Web/Program.cs @@ -244,6 +244,8 @@ // The project orchestrator (Autopilot): a pass over every autopilot board each minute, decisions logged per board. builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Per-ticket Microsoft Agent Framework workflows (Coding → checks → Reviewer → Testing → merge), started by Autopilot. +builder.Services.AddSingleton(); builder.Services.AddHostedService(); // Per-circuit UI state: each user's desktop has its own open windows. Singleton would bleed windows // (and their Z-order) across every connected circuit/user on the server. diff --git a/src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs b/src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs index 3b4776c..c5f0f19 100644 --- a/src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs +++ b/src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs @@ -34,6 +34,13 @@ public enum TaskStart NoGitHubAccess, } +/// A started task step: why it did or didn't start, and the work itself (completed when nothing started) — +/// awaited by a per-ticket MAF workflow, ignored by fire-and-forget callers. +public sealed record TaskRun(TaskStart Status, Task Completion) +{ + public static implicit operator TaskRun(TaskStart status) => new(status, Task.CompletedTask); +} + /// Starts Board tasks and follow-ups. Singleton; every call is tenant-explicit. public sealed class BoardTaskService( IServiceScopeFactory scopes, @@ -41,6 +48,14 @@ public sealed class BoardTaskService( TaskStopRegistry stops, IOptions pipelineOptions) { + private readonly SemaphoreSlim _testSlot = new(1, 1); + + private sealed class SlotRelease(SemaphoreSlim slot) : IDisposable + { + private int _done; + public void Dispose() { if (Interlocked.Exchange(ref _done, 1) == 0) { slot.Release(); } } + } + /// A repo a new task works in (the first one is the task's primary repo). public sealed record TaskRepo(string Owner, string Repo, string DefaultBranch, Guid? WorkspaceRepoId); @@ -98,6 +113,11 @@ await sessions.AddRepoForTenantAsync(new SessionRepoEntity /// pull request continues on its branch. Returns once the work is under way; the run reports on the session feed. /// public async Task StartAsync(string tenantId, Guid sessionId, string? changeRequest = null, string? cliProfile = null, + CancellationToken ct = default) => + (await BeginStartAsync(tenantId, sessionId, changeRequest, cliProfile, ct).ConfigureAwait(false)).Status; + + /// , returning the running work too. + public async Task BeginStartAsync(string tenantId, Guid sessionId, string? changeRequest = null, string? cliProfile = null, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); @@ -139,18 +159,22 @@ public async Task StartAsync(string tenantId, Guid sessionId, string? var stopLease = stops.Begin(tenantId, session.Id); // Fire-and-forget in a fresh scope, independent of whoever asked (a circuit, or the orchestrator). // The Thorough engine stores its own pipeline run + metrics; every other run is recorded here for Insights. - _ = Task.Run(() => EngineSelection.IsQuality(session.Brain) + var completion = Task.Run(() => EngineSelection.IsQuality(session.Brain) ? RunAsync(tenantId, session, board, pat, children, cliProfile, change, stopLease) : RecordedAsync(tenantId, session, "Coding", () => RunAsync(tenantId, session, board, pat, children, cliProfile, change, stopLease)), CancellationToken.None); - return TaskStart.Started; + return new TaskRun(TaskStart.Started, completion); } /// /// Agentic Testing: test every open proposed change of a task against its ticket's acceptance criteria on the /// member's computer, post the evidence on each pull request, and mark it Accepted or Rejected. /// - public async Task TestAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default) + public async Task TestAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default) => + (await BeginTestAsync(tenantId, sessionId, cliProfile, ct).ConfigureAwait(false)).Status; + + /// , returning the running work too. + public async Task BeginTestAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); using var scope = scopes.CreateScope(); @@ -174,9 +198,9 @@ public async Task TestAsync(string tenantId, Guid sessionId, string? await sessions.UpdateRepoRunResultAsync(tenantId, c.Id, "Testing", c.BranchName, c.PrUrl, null, ct).ConfigureAwait(false); } var stopLease = stops.Begin(tenantId, session.Id); - _ = Task.Run(() => RecordedAsync(tenantId, session, "Testing", () => TestChangesAsync(tenantId, session, board, pat, changes, cliProfile, stopLease)), + var completion = Task.Run(() => RecordedAsync(tenantId, session, "Testing", () => TestChangesAsync(tenantId, session, board, pat, changes, cliProfile, stopLease)), CancellationToken.None); - return TaskStart.Started; + return new TaskRun(TaskStart.Started, completion); } /// @@ -184,7 +208,11 @@ public async Task TestAsync(string tenantId, Guid sessionId, string? /// the member's computer, posts the review on each pull request, and marks it Reviewed or ChangesRequested (its /// must-fix findings become the change request the Coding agent gets next). /// - public async Task ReviewAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default) + public async Task ReviewAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default) => + (await BeginReviewAsync(tenantId, sessionId, cliProfile, ct).ConfigureAwait(false)).Status; + + /// , returning the running work too. + public async Task BeginReviewAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); using var scope = scopes.CreateScope(); @@ -208,9 +236,9 @@ public async Task ReviewAsync(string tenantId, Guid sessionId, string await sessions.UpdateRepoRunResultAsync(tenantId, c.Id, "Reviewing", c.BranchName, c.PrUrl, null, ct).ConfigureAwait(false); } var stopLease = stops.Begin(tenantId, session.Id); - _ = Task.Run(() => RecordedAsync(tenantId, session, "Reviewer", () => ReviewChangesAsync(tenantId, session, board, pat, changes, cliProfile, stopLease)), + var completion = Task.Run(() => RecordedAsync(tenantId, session, "Reviewer", () => ReviewChangesAsync(tenantId, session, board, pat, changes, cliProfile, stopLease)), CancellationToken.None); - return TaskStart.Started; + return new TaskRun(TaskStart.Started, completion); } private async Task ReviewChangesAsync(string tenantId, RemoteSessionEntity session, WorkspaceEntity board, string pat, @@ -270,6 +298,10 @@ private async Task TestChangesAsync(string tenantId, RemoteSessionEntity session using var _identity = AgentOs.SharedKernel.Identity.AmbientIdentity.Push(tenantId, session.MemberUserId, session.Id); var sessRepo = scope.ServiceProvider.GetRequiredService(); var tester = scope.ServiceProvider.GetRequiredService(); + // One acceptance run at a time, whoever asks (a MAF ticket workflow or a step-by-step pass): each brings the + // app up on the computer's fixed ports. + await _testSlot.WaitAsync(stopLease.Token).ConfigureAwait(false); + using var _slot = new SlotRelease(_testSlot); var prs = scope.ServiceProvider.GetRequiredService(); var body = await ReadTicketBodyAsync(scope, board, tenantId, session, pat).ConfigureAwait(false) ?? ""; foreach (var change in changes) diff --git a/src/AgentOs.Web/Services/Orchestration/BoardTicketSteps.cs b/src/AgentOs.Web/Services/Orchestration/BoardTicketSteps.cs new file mode 100644 index 0000000..897af33 --- /dev/null +++ b/src/AgentOs.Web/Services/Orchestration/BoardTicketSteps.cs @@ -0,0 +1,70 @@ +// The production ITicketSteps: each step of a ticket's MAF workflow is the Board's own agent (BoardTaskService — so +// scoped autonomy, metrics and evidence on the PR are the same as step by step) or a GitHub call. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AgentOs.Domain.Workspaces; +using AgentOs.Modules.Sessions.Persistence; +using AgentOs.Modules.Workspaces.Persistence.Entities; +using AgentOs.Web.Services.BoardTasks; +using Microsoft.Extensions.DependencyInjection; + +namespace AgentOs.Web.Services.Orchestration; + +/// Ticket workflow steps on a board. +public sealed class BoardTicketSteps(BoardTaskService tasks, IServiceScopeFactory scopes, WorkspaceEntity board, string pat) : ITicketSteps +{ + public Task CodeAsync(TicketFlow flow, CancellationToken ct) => + tasks.BeginStartAsync(flow.TenantId, flow.SessionId, flow.NextChange, ct: ct); + + public Task ReviewAsync(TicketFlow flow, CancellationToken ct) => tasks.BeginReviewAsync(flow.TenantId, flow.SessionId, ct: ct); + + public Task TestAsync(TicketFlow flow, CancellationToken ct) => tasks.BeginTestAsync(flow.TenantId, flow.SessionId, ct: ct); + + public async Task> ChangesAsync(TicketFlow flow, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(flow); + using var scope = scopes.CreateScope(); + return (await scope.ServiceProvider.GetRequiredService().ListReposForTenantAsync(flow.TenantId, flow.SessionId, ct).ConfigureAwait(false)) + .Select(r => new TicketChange(r.Id, r.Status, r.PrUrl, r.Error, r.DefaultBranch)).ToList(); + } + + public Task<(PrChecksState Checks, PrMergeState Merge)> PrStateAsync(TicketFlow flow, TicketChange change, CancellationToken ct) => + WithPrAsync(flow, change, ct, async (prs, desc, pr) => + (await prs.GetChecksStateAsync(desc, pr, ct).ConfigureAwait(false), await prs.GetMergeStateAsync(desc, pr, ct).ConfigureAwait(false))); + + public Task FailedChecksAsync(TicketFlow flow, TicketChange change, CancellationToken ct) => + WithPrAsync(flow, change, ct, (prs, desc, pr) => prs.GetFailedChecksAsync(desc, pr, ct)); + + public Task UpdateBranchAsync(TicketFlow flow, TicketChange change, CancellationToken ct) => + WithPrAsync(flow, change, ct, (prs, desc, pr) => prs.UpdateBranchAsync(desc, pr, ct)); + + public Task MergeAsync(TicketFlow flow, TicketChange change, CancellationToken ct) => + WithPrAsync(flow, change, ct, async (prs, desc, pr) => + { + if (await prs.MergeAsync(desc, pr, ct).ConfigureAwait(false) is { } error) { return error; } + using var scope = scopes.CreateScope(); + var sessions = scope.ServiceProvider.GetRequiredService(); + var repo = (await sessions.ListReposForTenantAsync(flow.TenantId, flow.SessionId, ct).ConfigureAwait(false)).First(r => r.Id == change.RepoId); + await sessions.UpdateRepoRunResultAsync(flow.TenantId, repo.Id, "Merged", repo.BranchName, repo.PrUrl, null, ct).ConfigureAwait(false); + await sessions.RecomputeSessionStatusForTenantAsync(flow.TenantId, flow.SessionId, ct).ConfigureAwait(false); + return (string?)null; + }); + + // Run a pull-request call for one change inside a scope (the source-control service and the change's repo). + private async Task WithPrAsync(TicketFlow flow, TicketChange change, CancellationToken ct, + Func> call) + { + ArgumentNullException.ThrowIfNull(flow); + ArgumentNullException.ThrowIfNull(change); + if (change.Pr is not { } pr) { throw new InvalidOperationException("The change has no pull request."); } + using var scope = scopes.CreateScope(); + var repo = (await scope.ServiceProvider.GetRequiredService().ListReposForTenantAsync(flow.TenantId, flow.SessionId, ct).ConfigureAwait(false)) + .First(r => r.Id == change.RepoId); + return await call(scope.ServiceProvider.GetRequiredService(), + BoardTaskService.RepoDescriptor(board, flow.TenantId, repo.Owner, repo.Repo, repo.DefaultBranch, pat), pr).ConfigureAwait(false); + } +} diff --git a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs index 5710a18..ef55200 100644 --- a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs +++ b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs @@ -143,8 +143,8 @@ private static async Task> WithAnswersAsync(List s.Id == stuck[i].SessionId); - if (Ticket(board, pat, session) is not { } repo) { continue; } - var answer = PersonAnswers.Latest(await prs.ListCommentsAsync(repo, stuck[i].IssueNumber, ct).ConfigureAwait(false)); + if (Ticket(board, pat, session) is not { } ticket) { continue; } + var answer = PersonAnswers.Latest(await prs.ListCommentsAsync(ticket.Repo, ticket.Issue, ct).ConfigureAwait(false)); if (answer is null) { continue; } // New information from a person is worth one more attempt even when the automatic ones are used up. stuck[i] = stuck[i] with { PersonAnswer = answer, RetriesLeft = Math.Max(1, stuck[i].RetriesLeft) }; @@ -152,22 +152,23 @@ private static async Task> WithAnswersAsync(List - session.IssueNumber is null || string.IsNullOrWhiteSpace(session.RepoOwner) || string.IsNullOrWhiteSpace(session.RepoName) + // The ticket's repo and number, when the task has a ticket. + private static (WorkspaceDescriptor Repo, int Issue)? Ticket(WorkspaceEntity board, string pat, RemoteSessionEntity session) => + session.IssueNumber is not { } issue || string.IsNullOrWhiteSpace(session.RepoOwner) || string.IsNullOrWhiteSpace(session.RepoName) ? null - : BoardTaskService.RepoDescriptor(board, board.TenantId, session.RepoOwner, session.RepoName, session.RepoDefaultBranch ?? "main", pat); + : (BoardTaskService.RepoDescriptor(board, board.TenantId, session.RepoOwner, session.RepoName, session.RepoDefaultBranch ?? "main", pat), issue); private static async Task CommentOnTicketAsync(WorkspaceEntity board, string pat, RemoteSessionEntity session, string body, IServiceProvider sp, CancellationToken ct) { - if (Ticket(board, pat, session) is { } repo) + if (Ticket(board, pat, session) is { } t) { - await sp.GetRequiredService().CommentAsync(repo, session.IssueNumber!.Value, body, ct).ConfigureAwait(false); + await sp.GetRequiredService().CommentAsync(t.Repo, t.Issue, body, ct).ConfigureAwait(false); } } private static async Task CloseTicketAsync(WorkspaceEntity board, string pat, RemoteSessionEntity session, string comment, IServiceProvider sp, CancellationToken ct) => - Ticket(board, pat, session) is { } repo - && await sp.GetRequiredService().CloseAsync(repo, session.IssueNumber!.Value, comment, ct).ConfigureAwait(false); + Ticket(board, pat, session) is { } t + && await sp.GetRequiredService().CloseAsync(t.Repo, t.Issue, comment, ct).ConfigureAwait(false); private readonly ConcurrentDictionary _supersededSweep = new(); @@ -185,10 +186,15 @@ private async Task CloseSupersededAsync(WorkspaceEntity board, string pat, IRead _supersededSweep[board.Id] = DateTimeOffset.UtcNow; var prs = sp.GetRequiredService(); // The pull request AgentOS tracks for each (repo, ticket): the one its task points at now. - var current = sessions.Where(s => s.IssueNumber is not null) - .SelectMany(s => repos[s.Id].Where(r => r.Status is not "Failed" && PrNumber(r.PrUrl) is not null) - .Select(r => (Key: (r.Owner, r.Repo, Issue: s.IssueNumber!.Value), Pr: PrNumber(r.PrUrl)!.Value, Repo: r))) - .GroupBy(x => x.Key).ToDictionary(g => g.Key, g => g.First()); + var current = new Dictionary<(string Owner, string Repo, int Issue), (int Pr, SessionRepoEntity Repo)>(); + foreach (var s in sessions) + { + if (s.IssueNumber is not { } issue) { continue; } + foreach (var r in repos[s.Id].Where(r => r.Status is not "Failed")) + { + if (PrNumber(r.PrUrl) is { } pr) { current.TryAdd((r.Owner, r.Repo, issue), (pr, r)); } + } + } foreach (var repo in current.Values.Select(c => c.Repo).DistinctBy(r => (r.Owner, r.Repo))) { var desc = BoardTaskService.RepoDescriptor(board, board.TenantId, repo.Owner, repo.Repo, repo.DefaultBranch, pat); @@ -212,12 +218,14 @@ private List StuckTasks(IReadOnlyList ticket IReadOnlyList sessions, ILookup repos, IReadOnlyList candidates) { var openTickets = planTickets.Where(t => t.Open).Select(t => t.Number).ToHashSet(); - var titles = tickets.Where(t => t.Number is not null).GroupBy(t => t.Number!.Value).ToDictionary(g => g.Key, g => g.First().Title); + var titles = new Dictionary(); + foreach (var t in tickets) { if (t.Number is { } number) { titles.TryAdd(number, t.Title); } } var stuck = new List(); - foreach (var s in sessions.Where(s => s.Status is "Failed" or "Blocked" && s.IssueNumber is { } n && openTickets.Contains(n))) + foreach (var s in sessions.Where(s => s.Status is "Failed" or "Blocked" && !workflows.IsRunning(s.TenantId, s.Id))) { + if (s.IssueNumber is not { } issue || !openTickets.Contains(issue)) { continue; } var why = string.Join(" | ", repos[s.Id].Select(r => r.Error).Prepend(s.Error).Where(e => !string.IsNullOrWhiteSpace(e)).Distinct()); - stuck.Add(new OrchestratorStuckTask(s.Id, s.IssueNumber!.Value, titles.GetValueOrDefault(s.IssueNumber.Value, s.Title), + stuck.Add(new OrchestratorStuckTask(s.Id, issue, titles.GetValueOrDefault(issue, s.Title), $"{(s.Status == "Failed" ? "Couldn't finish" : "Waiting for a person")}: {(why.Length == 0 ? "no reason recorded" : why)}", MaxAgentRetries - _agentRetries.GetValueOrDefault(s.Id))); } @@ -238,9 +246,10 @@ internal static string Snapshot(IReadOnlyList tickets, IReadOnlyLis var sb = new StringBuilder(); var plan = planTickets.ToDictionary(t => t.Number); sb.Append("Tickets:\n"); - foreach (var t in tickets.Where(t => t.Number is not null).OrderBy(t => t.Number)) + foreach (var t in tickets.OrderBy(t => t.Number)) { - var p = plan.GetValueOrDefault(t.Number!.Value); + if (t.Number is not { } number) { continue; } + var p = plan.GetValueOrDefault(number); sb.Append("#").Append(t.Number).Append(' ').Append(p?.Open == false ? "[closed]" : "[open]") .Append(" [").Append(string.Join(", ", t.Labels)).Append(']'); if (p is { DependsOn.Count: > 0 }) { sb.Append(" depends on ").Append(string.Join(", ", p.DependsOn.Select(d => "#" + d))); } diff --git a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.cs b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.cs index aa2a4cc..553b2cc 100644 --- a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.cs +++ b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.cs @@ -111,7 +111,8 @@ public sealed partial class ProjectOrchestrator( IRemoteAgentBroker broker, OrchestratorLog log, ILogger logger, - AgentOs.Web.Services.Preview.PreviewService previews) + AgentOs.Web.Services.Preview.PreviewService previews, + TicketWorkflowRunner workflows) { // Tasks seen "Running" with no live run here, by pass: one pass of grace (a run registers right after it is marked). private readonly ConcurrentDictionary _orphanSeen = new(); @@ -166,6 +167,12 @@ public async Task> StepAsync(WorkspaceEntity boar var planTasks = new List(); foreach (var s in sessions.Where(s => s.Status is "Running" or "Done")) { + // Its MAF workflow drives it: a running slot, and no step-by-step moves on it. + if (workflows.IsRunning(tenant, s.Id)) + { + planTasks.Add(new PlanTask(s.Id, s.IssueNumber ?? 0, "Running", [], _fixRounds.GetValueOrDefault(s.Id))); + continue; + } var open = new List(); foreach (var r in repos[s.Id].Where(r => r.Status is "Done" or "Reviewing" or "Reviewed" or "ChangesRequested" or "Testing" or "Accepted" or "Rejected" && PrNumber(r.PrUrl) is not null)) @@ -267,6 +274,15 @@ private async Task ExecuteAsync(AutopilotAction action, WorkspaceEntity board, s var session = await tasks.CreateAsync(tenant, member, board.Id, ticket.Number, $"#{ticket.Number} {ticket.Title}", ticket.ItemNodeId, ticket.Kind.ToString(), [new BoardTaskService.TaskRepo(ticket.RepoOwner!, ticket.RepoName!, curated?.DefaultBranch ?? "main", curated?.Id)], onComputer, ticketType: ticket.Labels.FirstOrDefault(l => l.StartsWith("type:", StringComparison.OrdinalIgnoreCase)), ct: ct).ConfigureAwait(false); + // A MAF workflow runs the ticket's whole agent flow (TicketWorkflow); step by step, the passes drive it. + var engine = await sp.GetRequiredService() + .GetAsync(AgentOs.Modules.Pipeline.Agents.AutopilotSettings.TicketEngineKey, tenant, ct).ConfigureAwait(false); + if (AgentOs.Modules.Pipeline.Agents.AutopilotSettings.UsesMafWorkflow(engine.Value) + && workflows.Start(board, pat, session.Id, start.Number, ParseMode(board.Autopilot) == AutopilotMode.BuildAndMerge)) + { + log.Add(tenant, board.Id, start.Why + (onComputer ? " (on your computer)" : " (on our servers)") + " — a MAF workflow runs it."); + break; + } var started = await tasks.StartAsync(tenant, session.Id, ct: ct).ConfigureAwait(false); log.Add(tenant, board.Id, started == TaskStart.Started ? start.Why + (onComputer ? " (on your computer)" : " (on our servers)") @@ -344,7 +360,7 @@ private async Task RecoverOrphansAsync(WorkspaceEntity board, IReadOnlyLis var changed = false; foreach (var s in sessions.Where(s => s.Status == "Running")) { - if (stops.IsRunning(tenant, s.Id)) { _orphanSeen.TryRemove(s.Id, out _); continue; } + if (stops.IsRunning(tenant, s.Id) || workflows.IsRunning(tenant, s.Id)) { _orphanSeen.TryRemove(s.Id, out _); continue; } if (_orphanSeen.AddOrUpdate(s.Id, 1, (_, n) => n + 1) < 2) { continue; } _orphanSeen.TryRemove(s.Id, out _); var children = repos[s.Id].ToList(); diff --git a/src/AgentOs.Web/Services/Orchestration/TicketWorkflow.cs b/src/AgentOs.Web/Services/Orchestration/TicketWorkflow.cs new file mode 100644 index 0000000..9ff6d44 --- /dev/null +++ b/src/AgentOs.Web/Services/Orchestration/TicketWorkflow.cs @@ -0,0 +1,285 @@ +// One ticket's whole agent flow as a Microsoft Agent Framework Workflow: the Autopilot Orchestrator agent decides +// WHICH ticket starts; this graph then runs it — Coding → checks → Reviewer → Testing → merge — routing each verdict +// along a conditional edge (red checks, conflicts, requested changes and failed acceptance go back to the Coding agent, +// bounded by the same 3 rounds the rules use). Each step is one of the existing agents (ITicketSteps — the Board's +// BoardTicketSteps in production), so governance, metrics (Insights) and evidence on the PR are unchanged. When the +// graph ends without a merge — "needs a person", or an agent that couldn't run — the task is handed back to the +// Orchestrator agent and the step-by-step rules, which also pick up any flow a restart cut short. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AgentOs.Domain.Workspaces; +using AgentOs.Web.Services.BoardTasks; +using Microsoft.Agents.AI.Workflows; + +namespace AgentOs.Web.Services.Orchestration; + +/// Where a ticket's flow is, as the next edge reads it. +public enum TicketStage +{ + /// The Coding agent pushed a change; its checks come next. + Coded, + /// A verdict sent the change back to the Coding agent (). + Fix, + /// Checks are green: review next. + Green, + /// The Reviewer approved: testing next. + Approved, + /// The Testing agent accepted it: merge next. + Accepted, + /// Merged — done. + Merged, + /// Accepted, but the board leaves merging to a person. + ReadyToMerge, + /// Out of automatic rounds — handed to the Orchestrator agent / a person. + NeedsPerson, + /// An agent couldn't run or finish — handed back to the step-by-step path. + Stopped, +} + +/// The state a ticket's workflow threads along every edge. +public sealed class TicketFlow(string tenantId, Guid boardId, Guid sessionId, int issueNumber, bool mayMerge) +{ + public string TenantId { get; } = tenantId; + public Guid BoardId { get; } = boardId; + public Guid SessionId { get; } = sessionId; + public int IssueNumber { get; } = issueNumber; + public bool MayMerge { get; } = mayMerge; + public TicketStage Stage { get; set; } = TicketStage.Fix; + /// Automatic fix rounds used. + public int Round { get; set; } + /// What the Coding agent is asked to change next (null = the ticket itself, first attempt). + public string? NextChange { get; set; } + /// Why the flow stopped (plain language), when it did. + public string? Reason { get; set; } + /// The steps taken, for the Autopilot log. + public List Trail { get; } = []; + + /// Send the change back to the Coding agent with — or stop when the rounds are used up. + public TicketFlow SendBack(string what, string change) + { + if (Round >= AutopilotPlanner.MaxFixRounds) + { + Stage = TicketStage.NeedsPerson; + Reason = $"{what} after {AutopilotPlanner.MaxFixRounds} automatic fixes — needs a person."; + return this; + } + Round++; + NextChange = change; + Stage = TicketStage.Fix; + Trail.Add($"{what.ToLowerInvariant()} → back to Coding (try {Round} of {AutopilotPlanner.MaxFixRounds})"); + return this; + } + + internal TicketFlow Stop(string why) + { + Stage = TicketStage.Stopped; + Reason = why; + return this; + } +} + +/// A proposed change of the ticket's task, as a step reads it. +public sealed record TicketChange(Guid RepoId, string Status, string? PrUrl, string? Error, string DefaultBranch) +{ + /// Its pull request number, when it has one. + public int? Pr => BoardTaskService.PrNumber(PrUrl); +} + +/// The steps a ticket workflow takes — each one an existing agent or a source-control call. +public interface ITicketSteps +{ + /// Start the Coding agent (with flow.NextChange as the change request, when set). + Task CodeAsync(TicketFlow flow, CancellationToken ct); + /// Start the Reviewer agent on the task's changes. + Task ReviewAsync(TicketFlow flow, CancellationToken ct); + /// Start the Testing agent on the task's changes. + Task TestAsync(TicketFlow flow, CancellationToken ct); + /// The task's proposed changes as they stand. + Task> ChangesAsync(TicketFlow flow, CancellationToken ct); + /// A change's pull request: its checks and whether it merges cleanly. + Task<(PrChecksState Checks, PrMergeState Merge)> PrStateAsync(TicketFlow flow, TicketChange change, CancellationToken ct); + /// What failed in a change's checks. + Task FailedChecksAsync(TicketFlow flow, TicketChange change, CancellationToken ct); + /// Bring a change that is behind its base up to date. + Task UpdateBranchAsync(TicketFlow flow, TicketChange change, CancellationToken ct); + /// Merge a change and record it; null on success, else the reason. + Task MergeAsync(TicketFlow flow, TicketChange change, CancellationToken ct); +} + +/// Builds and runs a ticket's MAF workflow. +public static class TicketWorkflow +{ + /// The graph. + public static Workflow Build(ITicketSteps steps, TimeSpan checksTimeout, TimeSpan pollInterval) + { + ArgumentNullException.ThrowIfNull(steps); + var code = new CodeExecutor(steps); + var checks = new ChecksExecutor(steps, checksTimeout, pollInterval); + var review = new ReviewExecutor(steps); + var test = new TestExecutor(steps); + var merge = new MergeExecutor(steps); + var done = new DoneExecutor(); + return new WorkflowBuilder(code) + .AddEdge(code, checks, (TicketFlow? f) => f?.Stage == TicketStage.Coded) + .AddEdge(code, done, (TicketFlow? f) => f is not null && f.Stage != TicketStage.Coded) + .AddEdge(checks, code, (TicketFlow? f) => f?.Stage == TicketStage.Fix) + .AddEdge(checks, review, (TicketFlow? f) => f?.Stage == TicketStage.Green) + .AddEdge(checks, done, (TicketFlow? f) => f is not null && f.Stage is not (TicketStage.Fix or TicketStage.Green)) + .AddEdge(review, code, (TicketFlow? f) => f?.Stage == TicketStage.Fix) + .AddEdge(review, test, (TicketFlow? f) => f?.Stage == TicketStage.Approved) + .AddEdge(review, done, (TicketFlow? f) => f is not null && f.Stage is not (TicketStage.Fix or TicketStage.Approved)) + .AddEdge(test, code, (TicketFlow? f) => f?.Stage == TicketStage.Fix) + .AddEdge(test, merge, (TicketFlow? f) => f?.Stage == TicketStage.Accepted) + .AddEdge(test, done, (TicketFlow? f) => f is not null && f.Stage is not (TicketStage.Fix or TicketStage.Accepted)) + .AddEdge(merge, done) + .WithOutputFrom(done) + .Build(); + } + + /// Run to its end; returns the final state. + public static async Task RunAsync(ITicketSteps steps, TicketFlow flow, TimeSpan checksTimeout, TimeSpan pollInterval, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(flow); + var run = await InProcessExecution.RunAsync(Build(steps, checksTimeout, pollInterval), flow, cancellationToken: ct).ConfigureAwait(false); + return run.NewEvents.OfType().Select(e => e.Data).OfType().LastOrDefault() ?? flow; + } + + private sealed class CodeExecutor(ITicketSteps steps) : Executor("code") + { + public override async ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) + { + var run = await steps.CodeAsync(f, ct).ConfigureAwait(false); + if (run.Status != TaskStart.Started) { return f.Stop($"The Coding agent couldn't start: {run.Status}."); } + await run.Completion.ConfigureAwait(false); + var changes = await steps.ChangesAsync(f, ct).ConfigureAwait(false); + if (changes.Count == 0 || changes.Any(c => c.Status is "Failed" or "Blocked" || c.Pr is null)) + { + return f.Stop(changes.Select(c => c.Error).FirstOrDefault(e => !string.IsNullOrWhiteSpace(e)) ?? "The Coding agent didn't finish."); + } + f.NextChange = null; + f.Stage = TicketStage.Coded; + f.Trail.Add("coded"); + return f; + } + } + + private sealed class ChecksExecutor(ITicketSteps steps, TimeSpan timeout, TimeSpan poll) : Executor("checks") + { + public override async ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) + { + var deadline = DateTimeOffset.UtcNow + timeout; + do + { + var states = new List<(TicketChange Change, PrChecksState Checks, PrMergeState Merge)>(); + foreach (var c in (await steps.ChangesAsync(f, ct).ConfigureAwait(false)).Where(c => c.Pr is not null)) + { + var (checks, merge) = await steps.PrStateAsync(f, c, ct).ConfigureAwait(false); + states.Add((c, checks, merge)); + } + if (states.Find(s => s.Merge == PrMergeState.Conflicts) is { Change: { } conflicted }) + { + return f.SendBack("Conflicts with its base", + $"Your branch conflicts with {conflicted.DefaultBranch} (another change was merged first). Merge origin/{conflicted.DefaultBranch} " + + "into it, resolve every conflict keeping the intent of both changes, build, run the tests, commit and push."); + } + var failed = states.Where(s => s.Checks == PrChecksState.Failed).ToList(); + if (failed.Count > 0) + { + var reports = new List(); + foreach (var s in failed) { reports.Add(await steps.FailedChecksAsync(f, s.Change, ct).ConfigureAwait(false) ?? $"PR #{s.Change.Pr}: checks failed."); } + return f.SendBack("Checks failed", + "The pull request's automated checks failed. Fix the cause (not the test, unless the test itself is wrong) so they pass:\n\n" + + string.Join("\n", reports)); + } + if (states.Count > 0 && states.All(s => s.Checks == PrChecksState.Passed)) + { + f.Stage = TicketStage.Green; + f.Trail.Add("checks green"); + return f; + } + foreach (var behind in states.Where(s => s.Merge == PrMergeState.Behind)) + { + await steps.UpdateBranchAsync(f, behind.Change, ct).ConfigureAwait(false); + } + await Task.Delay(poll, ct).ConfigureAwait(false); + } + while (DateTimeOffset.UtcNow < deadline); + return f.Stop($"Checks didn't finish within {timeout.TotalMinutes:0} minutes."); + } + } + + private sealed class ReviewExecutor(ITicketSteps steps) : Executor("review") + { + public override async ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) + { + var run = await steps.ReviewAsync(f, ct).ConfigureAwait(false); + if (run.Status != TaskStart.Started) { return f.Stop($"The Reviewer agent couldn't start: {run.Status}."); } + await run.Completion.ConfigureAwait(false); + var changes = await steps.ChangesAsync(f, ct).ConfigureAwait(false); + if (changes.Any(c => c.Status == "ChangesRequested")) + { + return f.SendBack("Code review asked for changes", + string.Join("\n", changes.Where(c => c.Status == "ChangesRequested").Select(c => c.Error).Where(e => !string.IsNullOrWhiteSpace(e)))); + } + if (changes.Count > 0 && changes.All(c => c.Status == "Reviewed")) + { + f.Stage = TicketStage.Approved; + f.Trail.Add("review approved"); + return f; + } + return f.Stop("The Reviewer agent couldn't review the change."); + } + } + + private sealed class TestExecutor(ITicketSteps steps) : Executor("test") + { + public override async ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) + { + var run = await steps.TestAsync(f, ct).ConfigureAwait(false); + if (run.Status != TaskStart.Started) { return f.Stop($"The Testing agent couldn't start: {run.Status}."); } + await run.Completion.ConfigureAwait(false); + var changes = await steps.ChangesAsync(f, ct).ConfigureAwait(false); + if (changes.Any(c => c.Status == "Rejected")) + { + return f.SendBack("Acceptance testing failed", + string.Join("\n", changes.Where(c => c.Status == "Rejected").Select(c => c.Error).Where(e => !string.IsNullOrWhiteSpace(e)))); + } + if (changes.Count > 0 && changes.All(c => c.Status == "Accepted")) + { + f.Stage = TicketStage.Accepted; + f.Trail.Add("acceptance passed"); + return f; + } + return f.Stop("The Testing agent couldn't test the change."); + } + } + + private sealed class MergeExecutor(ITicketSteps steps) : Executor("merge") + { + public override async ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) + { + if (!f.MayMerge) + { + f.Stage = TicketStage.ReadyToMerge; + f.Trail.Add("ready for you to merge"); + return f; + } + foreach (var c in (await steps.ChangesAsync(f, ct).ConfigureAwait(false)).Where(c => c.Pr is not null)) + { + if (await steps.MergeAsync(f, c, ct).ConfigureAwait(false) is { } error) { return f.Stop($"Couldn't merge PR #{c.Pr}: {error}"); } + } + f.Stage = TicketStage.Merged; + f.Trail.Add("merged"); + return f; + } + } + + private sealed class DoneExecutor() : Executor("done") + { + public override ValueTask HandleAsync(TicketFlow f, IWorkflowContext context, CancellationToken ct) => ValueTask.FromResult(f); + } +} diff --git a/src/AgentOs.Web/Services/Orchestration/TicketWorkflowRunner.cs b/src/AgentOs.Web/Services/Orchestration/TicketWorkflowRunner.cs new file mode 100644 index 0000000..b77bf73 --- /dev/null +++ b/src/AgentOs.Web/Services/Orchestration/TicketWorkflowRunner.cs @@ -0,0 +1,85 @@ +// Hosts the per-ticket MAF workflows (TicketWorkflow) in the background — one per ticket the Autopilot Orchestrator +// starts. While a ticket's workflow runs, Autopilot leaves that task alone (it counts as a running slot); when it ends, +// its trail goes to the Autopilot log and anything unfinished is handed back: out of rounds → marked for a person (the +// Orchestrator agent may retry it with guidance or ask); an agent that couldn't run → the step-by-step path continues +// from where the task stands. A restart drops the in-memory runs; the same step-by-step path picks those tasks up. + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AgentOs.Modules.Sessions.Persistence; +using AgentOs.Modules.Workspaces.Persistence.Entities; +using AgentOs.Web.Services.BoardTasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace AgentOs.Web.Services.Orchestration; + +/// Runs ticket workflows. Singleton. +public sealed class TicketWorkflowRunner(IServiceScopeFactory scopes, BoardTaskService tasks, OrchestratorLog log, ILogger logger) +{ + private readonly ConcurrentDictionary<(string Tenant, Guid Session), Task> _running = new(); + /// How long a workflow waits for a pull request's checks. + public TimeSpan ChecksTimeout { get; init; } = TimeSpan.FromMinutes(60); + + /// How often it looks at them. + public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(60); + + /// True while the task's workflow runs. + public bool IsRunning(string tenantId, Guid sessionId) => _running.ContainsKey((tenantId, sessionId)); + + /// Start the ticket's workflow; false when one already runs for it. + public bool Start(WorkspaceEntity board, string pat, Guid sessionId, int issueNumber, bool mayMerge) + { + ArgumentNullException.ThrowIfNull(board); + var key = (board.TenantId, sessionId); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_running.TryAdd(key, gate.Task)) { return false; } + _ = Task.Run(async () => + { + try + { + var flow = await TicketWorkflow.RunAsync(new BoardTicketSteps(tasks, scopes, board, pat), + new TicketFlow(board.TenantId, board.Id, sessionId, issueNumber, mayMerge), ChecksTimeout, PollInterval, CancellationToken.None) + .ConfigureAwait(false); + await FinishAsync(board, flow).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Ticket workflow for #{Issue} failed", issueNumber); + log.Add(board.TenantId, board.Id, $"#{issueNumber} · MAF workflow stopped unexpectedly ({ex.Message}) — Autopilot continues step by step."); + } + finally + { + _running.TryRemove(key, out _); + gate.TrySetResult(); + } + }); + return true; + } + + private async Task FinishAsync(WorkspaceEntity board, TicketFlow flow) + { + var steps = flow.Trail.Count == 0 ? "" : " (" + string.Join(" → ", flow.Trail) + ")"; + var line = flow.Stage switch + { + TicketStage.Merged => $"#{flow.IssueNumber} · MAF workflow: merged{steps}.", + TicketStage.ReadyToMerge => $"#{flow.IssueNumber} · MAF workflow: reviewed and tested — ready for you to merge{steps}.", + TicketStage.NeedsPerson => $"#{flow.IssueNumber} · MAF workflow: {flow.Reason}{steps}", + _ => $"#{flow.IssueNumber} · MAF workflow paused: {flow.Reason}{steps} — Autopilot continues step by step.", + }; + log.Add(board.TenantId, board.Id, line); + if (flow.Stage != TicketStage.NeedsPerson) { return; } + // Same as the rules' "needs a person": the open changes are blocked, which the Orchestrator agent sees as stuck. + using var scope = scopes.CreateScope(); + var sessions = scope.ServiceProvider.GetRequiredService(); + foreach (var r in (await sessions.ListReposForTenantAsync(board.TenantId, flow.SessionId).ConfigureAwait(false)) + .Where(r => r.Status is "Done" or "Reviewed" or "ChangesRequested" or "Accepted" or "Rejected")) + { + await sessions.UpdateRepoRunResultAsync(board.TenantId, r.Id, "Blocked", r.BranchName, r.PrUrl, flow.Reason).ConfigureAwait(false); + } + await sessions.RecomputeSessionStatusForTenantAsync(board.TenantId, flow.SessionId).ConfigureAwait(false); + } +} diff --git a/tests/AgentOs.Tests/Web/TicketWorkflowTests.cs b/tests/AgentOs.Tests/Web/TicketWorkflowTests.cs new file mode 100644 index 0000000..6a1f906 --- /dev/null +++ b/tests/AgentOs.Tests/Web/TicketWorkflowTests.cs @@ -0,0 +1,202 @@ +// The per-ticket Microsoft Agent Framework workflow, run for real (InProcessExecution) against scripted steps: every +// route of the graph — green path, red checks, conflicts, a branch behind its base, requested changes, failed +// acceptance, the 3-round cap, an agent that couldn't finish, checks that never finish, and a board that leaves the +// merge to a person. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AgentOs.Domain.Workspaces; +using AgentOs.Web.Services.BoardTasks; +using AgentOs.Web.Services.Orchestration; +using Shouldly; +using Xunit; + +namespace AgentOs.Tests.Web; + +public sealed class TicketWorkflowTests +{ + private sealed class ScriptedSteps : ITicketSteps + { + private string _status = "Pending"; + private string? _error; + private string? _pr; + public Queue Coding { get; } = new(); // "ok" | "fail" + public Queue<(PrChecksState, PrMergeState)> Checks { get; } = new(); + public Queue Reviews { get; } = new(); // "Reviewed" | "ChangesRequested" + public Queue Tests { get; } = new(); // "Accepted" | "Rejected" + public List CodeRequests { get; } = []; + public int Updates { get; private set; } + public int Merges { get; private set; } + + private static Task Started() => Task.FromResult(new TaskRun(TaskStart.Started, Task.CompletedTask)); + + public Task CodeAsync(TicketFlow flow, CancellationToken ct) + { + CodeRequests.Add(flow.NextChange); + var outcome = Coding.Count > 0 ? Coding.Dequeue() : "ok"; + if (outcome == "ok") { _status = "Done"; _pr = "https://github.com/o/r/pull/50"; _error = null; } + else { _status = "Failed"; _pr = null; _error = "push rejected"; } + return Started(); + } + + public Task ReviewAsync(TicketFlow flow, CancellationToken ct) + { + _status = Reviews.Dequeue(); + _error = _status == "ChangesRequested" ? "- Viewer.tsx:42: off-by-one" : null; + return Started(); + } + + public Task TestAsync(TicketFlow flow, CancellationToken ct) + { + _status = Tests.Dequeue(); + _error = _status == "Rejected" ? "- LIB-3: ArrowRight does nothing" : null; + return Started(); + } + + public Task> ChangesAsync(TicketFlow flow, CancellationToken ct) => + Task.FromResult>([new(Guid.Empty, _status, _pr, _error, "main")]); + + public Task<(PrChecksState Checks, PrMergeState Merge)> PrStateAsync(TicketFlow flow, TicketChange change, CancellationToken ct) => + Task.FromResult(Checks.Count > 0 ? Checks.Dequeue() : (PrChecksState.Pending, PrMergeState.Clean)); + + public Task FailedChecksAsync(TicketFlow flow, TicketChange change, CancellationToken ct) => + Task.FromResult("backend: 2 tests failed"); + + public Task UpdateBranchAsync(TicketFlow flow, TicketChange change, CancellationToken ct) { Updates++; return Task.CompletedTask; } + + public Task MergeAsync(TicketFlow flow, TicketChange change, CancellationToken ct) { Merges++; _status = "Merged"; return Task.FromResult(null); } + } + + private static readonly (PrChecksState, PrMergeState) Green = (PrChecksState.Passed, PrMergeState.Clean); + + private static Task Run(ScriptedSteps steps, bool mayMerge = true, TimeSpan? checksTimeout = null) => + TicketWorkflow.RunAsync(steps, new TicketFlow("t", Guid.NewGuid(), Guid.NewGuid(), 15, mayMerge), + checksTimeout ?? TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(1), CancellationToken.None); + + [Fact] + public async Task GreenPath_CodesChecksReviewsTestsAndMerges() + { + var steps = new ScriptedSteps(); + steps.Checks.Enqueue((PrChecksState.Pending, PrMergeState.Clean)); + steps.Checks.Enqueue(Green); + steps.Reviews.Enqueue("Reviewed"); + steps.Tests.Enqueue("Accepted"); + + var flow = await Run(steps); + + flow.Stage.ShouldBe(TicketStage.Merged); + flow.Round.ShouldBe(0); + flow.Trail.ShouldBe(["coded", "checks green", "review approved", "acceptance passed", "merged"]); + steps.Merges.ShouldBe(1); + } + + [Fact] + public async Task RedChecks_GoBackToCoding_WithTheReport() + { + var steps = new ScriptedSteps(); + steps.Checks.Enqueue((PrChecksState.Failed, PrMergeState.Clean)); + steps.Checks.Enqueue(Green); + steps.Reviews.Enqueue("Reviewed"); + steps.Tests.Enqueue("Accepted"); + + var flow = await Run(steps); + + flow.Stage.ShouldBe(TicketStage.Merged); + flow.Round.ShouldBe(1); + steps.CodeRequests.Count.ShouldBe(2); + steps.CodeRequests[1]!.ShouldContain("backend: 2 tests failed"); + } + + [Fact] + public async Task Conflicts_GoBackToCoding_ToMergeTheBase() + { + var steps = new ScriptedSteps(); + steps.Checks.Enqueue((PrChecksState.Passed, PrMergeState.Conflicts)); + steps.Checks.Enqueue(Green); + steps.Reviews.Enqueue("Reviewed"); + steps.Tests.Enqueue("Accepted"); + + var flow = await Run(steps); + + flow.Stage.ShouldBe(TicketStage.Merged); + steps.CodeRequests[1]!.ShouldContain("Merge origin/main"); + } + + [Fact] + public async Task BehindItsBase_IsUpdated_ThenGoesOn() + { + var steps = new ScriptedSteps(); + steps.Checks.Enqueue((PrChecksState.Pending, PrMergeState.Behind)); + steps.Checks.Enqueue(Green); + steps.Reviews.Enqueue("Reviewed"); + steps.Tests.Enqueue("Accepted"); + + (await Run(steps)).Stage.ShouldBe(TicketStage.Merged); + steps.Updates.ShouldBe(1); + } + + [Fact] + public async Task RequestedChangesAndFailedAcceptance_GoBackToCoding_WithTheFindings() + { + var steps = new ScriptedSteps(); + for (var i = 0; i < 3; i++) { steps.Checks.Enqueue(Green); } + steps.Reviews.Enqueue("ChangesRequested"); + steps.Reviews.Enqueue("Reviewed"); + steps.Reviews.Enqueue("Reviewed"); + steps.Tests.Enqueue("Rejected"); + steps.Tests.Enqueue("Accepted"); + + var flow = await Run(steps); + + flow.Stage.ShouldBe(TicketStage.Merged); + flow.Round.ShouldBe(2); + steps.CodeRequests[1]!.ShouldContain("off-by-one"); + steps.CodeRequests[2]!.ShouldContain("ArrowRight"); + } + + [Fact] + public async Task ThreeRoundsUsed_NeedsAPerson() + { + var steps = new ScriptedSteps(); + for (var i = 0; i < 4; i++) { steps.Checks.Enqueue(Green); steps.Reviews.Enqueue("ChangesRequested"); } + + var flow = await Run(steps); + + flow.Stage.ShouldBe(TicketStage.NeedsPerson); + flow.Round.ShouldBe(3); + flow.Reason!.ShouldContain("needs a person"); + steps.Merges.ShouldBe(0); + } + + [Fact] + public async Task CodingCouldNotFinish_StopsWithTheReason() + { + var steps = new ScriptedSteps(); + steps.Coding.Enqueue("fail"); + + var flow = await Run(steps); + + flow.Stage.ShouldBe(TicketStage.Stopped); + flow.Reason.ShouldBe("push rejected"); + } + + [Fact] + public async Task ChecksNeverFinish_StopAfterTheTimeout() => + (await Run(new ScriptedSteps(), checksTimeout: TimeSpan.FromMilliseconds(30))).Stage.ShouldBe(TicketStage.Stopped); + + [Fact] + public async Task BoardWithoutAutoMerge_EndsReadyForAPerson() + { + var steps = new ScriptedSteps(); + steps.Checks.Enqueue(Green); + steps.Reviews.Enqueue("Reviewed"); + steps.Tests.Enqueue("Accepted"); + + var flow = await Run(steps, mayMerge: false); + + flow.Stage.ShouldBe(TicketStage.ReadyToMerge); + steps.Merges.ShouldBe(0); + } +}