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);
+ }
+}