Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/plans/autonomy-levels.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,27 @@ public sealed class AutopilotSettings : ISettingsContributor
/// <summary>The fixed rules decide.</summary>
public const string Rules = "Rules";

/// <summary>How each started ticket runs.</summary>
public const string TicketEngineKey = "Autopilot:TicketEngine";

/// <summary>A Microsoft Agent Framework workflow runs the ticket's whole agent flow.</summary>
public const string MafWorkflow = "MAF workflow";

/// <summary>Autopilot drives each step itself, pass by pass.</summary>
public const string StepByStep = "Step by step";

/// <summary>True when the stored value means a MAF workflow (anything but "Step by step").</summary>
public static bool UsesMafWorkflow(string? value) => !string.Equals(value?.Trim(), StepByStep, System.StringComparison.OrdinalIgnoreCase);

/// <inheritdoc />
public IEnumerable<SettingDescriptor> 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,
Expand Down
2 changes: 2 additions & 0 deletions src/AgentOs.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@
// The project orchestrator (Autopilot): a pass over every autopilot board each minute, decisions logged per board.
builder.Services.AddSingleton<AgentOs.Web.Services.Orchestration.OrchestratorLog>();
builder.Services.AddSingleton<AgentOs.Web.Services.Orchestration.ProjectOrchestrator>();
// Per-ticket Microsoft Agent Framework workflows (Coding → checks → Reviewer → Testing → merge), started by Autopilot.
builder.Services.AddSingleton<AgentOs.Web.Services.Orchestration.TicketWorkflowRunner>();
builder.Services.AddHostedService<AgentOs.Web.Services.Orchestration.ProjectOrchestratorWorker>();
// 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.
Expand Down
48 changes: 40 additions & 8 deletions src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,28 @@
NoGitHubAccess,
}

/// <summary>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.</summary>
public sealed record TaskRun(TaskStart Status, Task Completion)
{
public static implicit operator TaskRun(TaskStart status) => new(status, Task.CompletedTask);
}

/// <summary>Starts Board tasks and follow-ups. Singleton; every call is tenant-explicit.</summary>
public sealed class BoardTaskService(
IServiceScopeFactory scopes,
ISessionRunFeed feed,
TaskStopRegistry stops,
IOptions<PipelineOptions> 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(); } }
}

/// <summary>A repo a new task works in (the first one is the task's primary repo).</summary>
public sealed record TaskRepo(string Owner, string Repo, string DefaultBranch, Guid? WorkspaceRepoId);

Expand Down Expand Up @@ -98,6 +113,11 @@
/// pull request continues on its branch. Returns once the work is under way; the run reports on the session feed.
/// </summary>
public async Task<TaskStart> StartAsync(string tenantId, Guid sessionId, string? changeRequest = null, string? cliProfile = null,
CancellationToken ct = default) =>
(await BeginStartAsync(tenantId, sessionId, changeRequest, cliProfile, ct).ConfigureAwait(false)).Status;

/// <summary><see cref="StartAsync"/>, returning the running work too.</summary>
public async Task<TaskRun> BeginStartAsync(string tenantId, Guid sessionId, string? changeRequest = null, string? cliProfile = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
Expand Down Expand Up @@ -139,18 +159,22 @@
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);
}

/// <summary>
/// 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.
/// </summary>
public async Task<TaskStart> TestAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default)
public async Task<TaskStart> TestAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default) =>
(await BeginTestAsync(tenantId, sessionId, cliProfile, ct).ConfigureAwait(false)).Status;

/// <summary><see cref="TestAsync"/>, returning the running work too.</summary>
public async Task<TaskRun> BeginTestAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
using var scope = scopes.CreateScope();
Expand All @@ -174,17 +198,21 @@
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);
}

/// <summary>
/// Agentic review: the Reviewer agent reads every proposed change of a task (still unreviewed) against its ticket on
/// 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).
/// </summary>
public async Task<TaskStart> ReviewAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default)
public async Task<TaskStart> ReviewAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default) =>
(await BeginReviewAsync(tenantId, sessionId, cliProfile, ct).ConfigureAwait(false)).Status;

/// <summary><see cref="ReviewAsync"/>, returning the running work too.</summary>
public async Task<TaskRun> BeginReviewAsync(string tenantId, Guid sessionId, string? cliProfile = null, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
using var scope = scopes.CreateScope();
Expand All @@ -208,9 +236,9 @@
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,
Expand Down Expand Up @@ -270,6 +298,10 @@
using var _identity = AgentOs.SharedKernel.Identity.AmbientIdentity.Push(tenantId, session.MemberUserId, session.Id);
var sessRepo = scope.ServiceProvider.GetRequiredService<ISessionRepository>();
var tester = scope.ServiceProvider.GetRequiredService<IAcceptanceAgent>();
// 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<IPrCreationService>();
var body = await ReadTicketBodyAsync(scope, board, tenantId, session, pat).ConfigureAwait(false) ?? "";
foreach (var change in changes)
Expand Down Expand Up @@ -601,7 +633,7 @@
if (cls != TicketClass.Greenfield)
{
var reason = TicketClassifier.RefusalMessage(cls);
await sessRepo.UpdateRunResultAsync(tenantId, session.Id, "Failed", null, reason).ConfigureAwait(false);

Check warning on line 636 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
Publish(SessionRunEventKind.Failed, reason);
return;
}
Expand All @@ -610,7 +642,7 @@
if (primary is null)
{
const string noRepo = "Quality run has no target repo — recreate the session from a board ticket.";
await sessRepo.UpdateRunResultAsync(tenantId, session.Id, "Failed", null, noRepo).ConfigureAwait(false);

Check warning on line 645 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
Publish(SessionRunEventKind.Failed, noRepo);
return;
}
Expand Down Expand Up @@ -653,8 +685,8 @@
if (result is null || result.Status == PipelineStatus.Failed)
{
var msg = error ?? "Quality pipeline failed — see the server log.";
await sessRepo.UpdateRepoRunResultAsync(tenantId, primary.Id, "Failed", null, null, msg).ConfigureAwait(false);

Check warning on line 688 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRepoRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
await sessRepo.UpdateRunResultAsync(tenantId, session.Id, "Failed", null, msg).ConfigureAwait(false);

Check warning on line 689 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
Publish(SessionRunEventKind.Failed, $"{repoLabel} — {msg}", repoLabel, "Failed");
return;
}
Expand All @@ -663,8 +695,8 @@
var gate = PrGate.Evaluate(result);
if (!gate.Allowed)
{
await sessRepo.UpdateRepoRunResultAsync(tenantId, primary.Id, "Blocked", null, null, gate.Reason).ConfigureAwait(false);

Check warning on line 698 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'UpdateRepoRunResultAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
await sessRepo.RecomputeSessionStatusForTenantAsync(tenantId, session.Id).ConfigureAwait(false);

Check warning on line 699 in src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Forward the 'stop' parameter to the 'RecomputeSessionStatusForTenantAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)
Publish(SessionRunEventKind.Failed, $"{repoLabel} — {gate.Reason}", repoLabel, "Blocked");
return;
}
Expand Down
70 changes: 70 additions & 0 deletions src/AgentOs.Web/Services/Orchestration/BoardTicketSteps.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Ticket workflow steps on a board.</summary>
public sealed class BoardTicketSteps(BoardTaskService tasks, IServiceScopeFactory scopes, WorkspaceEntity board, string pat) : ITicketSteps
{
public Task<TaskRun> CodeAsync(TicketFlow flow, CancellationToken ct) =>
tasks.BeginStartAsync(flow.TenantId, flow.SessionId, flow.NextChange, ct: ct);

public Task<TaskRun> ReviewAsync(TicketFlow flow, CancellationToken ct) => tasks.BeginReviewAsync(flow.TenantId, flow.SessionId, ct: ct);

public Task<TaskRun> TestAsync(TicketFlow flow, CancellationToken ct) => tasks.BeginTestAsync(flow.TenantId, flow.SessionId, ct: ct);

public async Task<IReadOnlyList<TicketChange>> ChangesAsync(TicketFlow flow, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(flow);
using var scope = scopes.CreateScope();
return (await scope.ServiceProvider.GetRequiredService<ISessionRepository>().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<string?> FailedChecksAsync(TicketFlow flow, TicketChange change, CancellationToken ct) =>
WithPrAsync(flow, change, ct, (prs, desc, pr) => prs.GetFailedChecksAsync(desc, pr, ct));

Check warning on line 40 in src/AgentOs.Web/Services/Orchestration/BoardTicketSteps.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Nullability of reference types in value of type 'Task<string>' doesn't match target type 'Task<string?>'.

public Task UpdateBranchAsync(TicketFlow flow, TicketChange change, CancellationToken ct) =>
WithPrAsync<string?>(flow, change, ct, (prs, desc, pr) => prs.UpdateBranchAsync(desc, pr, ct));

public Task<string?> 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<ISessionRepository>();
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;

Check warning on line 54 in src/AgentOs.Web/Services/Orchestration/BoardTicketSteps.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Cast is redundant. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0004)
});

// Run a pull-request call for one change inside a scope (the source-control service and the change's repo).
private async Task<T> WithPrAsync<T>(TicketFlow flow, TicketChange change, CancellationToken ct,

Check warning on line 58 in src/AgentOs.Web/Services/Orchestration/BoardTicketSteps.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Method 'AgentOs.Web.Services.Orchestration.BoardTicketSteps.WithPrAsync<T>(AgentOs.Web.Services.Orchestration.TicketFlow, AgentOs.Web.Services.Orchestration.TicketChange, System.Threading.CancellationToken, System.Func<AgentOs.Domain.Workspaces.IPrCreationService, AgentOs.Domain.Workspaces.WorkspaceDescriptor, int, System.Threading.Tasks.Task<T>>)' should take CancellationToken as the last parameter (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1068)
Func<IPrCreationService, WorkspaceDescriptor, int, Task<T>> 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<ISessionRepository>().ListReposForTenantAsync(flow.TenantId, flow.SessionId, ct).ConfigureAwait(false))
.First(r => r.Id == change.RepoId);
return await call(scope.ServiceProvider.GetRequiredService<IPrCreationService>(),
BoardTaskService.RepoDescriptor(board, flow.TenantId, repo.Owner, repo.Repo, repo.DefaultBranch, pat), pr).ConfigureAwait(false);
}
}
Loading
Loading