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
12 changes: 11 additions & 1 deletion src/AgentOs.Domain/Sessions/IIssueWorkAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,17 @@ namespace AgentOs.Domain.Sessions;
/// <param name="DefaultBranch">Base branch the fix branch is cut from.</param>
/// <param name="ExistingBranch">Set for "Ask for changes": the branch of this repo's open pull request. The agent
/// checks it out and pushes more commits to it instead of starting a new branch.</param>
public sealed record WorkRepo(Guid SessionRepoId, string Owner, string Repo, string DefaultBranch, string? ExistingBranch = null);
public sealed record WorkRepo(Guid SessionRepoId, string Owner, string Repo, string DefaultBranch, string? ExistingBranch = null)
{
/// <summary>The branch a fresh attempt creates, chosen by the server so it never collides with one an earlier,
/// interrupted attempt left on the remote (that push would need a force-push, which the agent may not do).
/// Null = the default <c>issue-N-ai-fix</c>.</summary>
public string? NewBranch { get; init; }

/// <summary>The branch a ticket's first attempt uses.</summary>
public static string DefaultBranchName(int issueNumber) =>
"issue-" + issueNumber.ToString(System.Globalization.CultureInfo.InvariantCulture) + "-ai-fix";
}

/// <summary>Request to the issue-work agent — a ticket plus the repos it should be implemented in.</summary>
/// <param name="SessionId">Correlating session row for evidence / logging.</param>
Expand Down
4 changes: 4 additions & 0 deletions src/AgentOs.Domain/Workspaces/IPrCreationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ Task<PrMergeState> GetMergeStateAsync(WorkspaceDescriptor workspace, int number,
Task<string?> UpdateBranchAsync(WorkspaceDescriptor workspace, int number, CancellationToken ct = default) =>
Task.FromResult<string?>("Updating a branch is not supported for this provider.");

/// <summary>True when <paramref name="branch"/> exists on the remote. Null when it couldn't be checked.</summary>
Task<bool?> BranchExistsAsync(WorkspaceDescriptor workspace, string branch, CancellationToken ct = default) =>
Task.FromResult<bool?>(null);

/// <summary>Post a comment on pull request <paramref name="number"/> (the Testing agent's evidence). Never throws.</summary>
Task<bool> CommentAsync(WorkspaceDescriptor workspace, int number, string body, CancellationToken ct = default) =>
Task.FromResult(false);
Expand Down
22 changes: 22 additions & 0 deletions src/AgentOs.Modules.Integration/GitHubPrCreationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,28 @@ internal static PrMergeState MergeStateOf(bool? mergeable, MergeableState? state
}
}

/// <inheritdoc />
public async Task<bool?> BranchExistsAsync(WorkspaceDescriptor workspace, string branch, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(workspace);
if (workspace.Kind != SourceProviderKind.GitHub || string.IsNullOrWhiteSpace(branch)) { return null; }
try
{
var client = GitHubClientFactory.Create(workspace.AccessToken, workspace.Host);
await client.Repository.Branch.Get(workspace.Owner, workspace.Repo, branch).ConfigureAwait(false);
return true;
}
catch (NotFoundException)
{
return false;
}
catch (Exception ex) when (ex is ApiException or System.Net.Http.HttpRequestException or TaskCanceledException)
{
_logger.LogWarning(ex, "Couldn't check branch {Branch} on {Owner}/{Repo}", branch, workspace.Owner, workspace.Repo);
return null;
}
}

/// <inheritdoc />
public async Task<bool> CommentAsync(WorkspaceDescriptor workspace, int number, string body, CancellationToken ct = default)
{
Expand Down
12 changes: 8 additions & 4 deletions src/AgentOs.Modules.Pipeline/Prompts/IssueWorkPrompt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,11 @@ private static bool IsRevision(IssueWorkRequest req, WorkRepo repo) =>
!string.IsNullOrWhiteSpace(req.ChangeRequest) && !string.IsNullOrWhiteSpace(repo.ExistingBranch);

internal static string BranchName(IssueWorkRequest req, WorkRepo repo) =>
IsRevision(req, repo) ? repo.ExistingBranch! : $"issue-{req.IssueNumber.ToString(CultureInfo.InvariantCulture)}-ai-fix";
IsRevision(req, repo) ? repo.ExistingBranch! : FreshBranch(req, repo);

// A fresh attempt's branch: the server's pick when an older attempt's branch is still on the remote.
private static string FreshBranch(IssueWorkRequest req, WorkRepo repo) =>
string.IsNullOrWhiteSpace(repo.NewBranch) ? WorkRepo.DefaultBranchName(req.IssueNumber) : repo.NewBranch;

// Step 3 of the workflow. The prompt's steps start at column 0 with continuation lines indented by 3, so the
// inserted text follows the same shape.
Expand All @@ -173,7 +177,7 @@ private static string BranchStep(IssueWorkRequest req, WorkRepo repo) => IsRevis
" Read the requested changes in the user message and address every point.\n")
: string.Concat(
"3. Create a feature branch off the default branch (", repo.DefaultBranch, "):\n",
" git checkout -b issue-", req.IssueNumber.ToString(CultureInfo.InvariantCulture), "-ai-fix\n");
" git checkout -b ", FreshBranch(req, repo), "\n");

// The commit type follows the ticket ("feat" for a feature, "fix" for a bug…), like the pull request title.
private static string CommitType(IssueWorkRequest req) => AgentOs.Domain.Workspaces.PullRequestText.ConventionalType(req.TicketType);
Expand All @@ -190,9 +194,9 @@ private static string Instruction(IssueWorkRequest req, WorkRepo repo) => IsRevi
""",
repo.ExistingBranch, UntrustedText.Wrap("requested-changes", req.ChangeRequest!, maxChars: 16_000))
: string.Format(CultureInfo.InvariantCulture,
"Implement a fix for this ticket in THIS repository. Create branch `issue-{0}-ai-fix`,\n"
"Implement a fix for this ticket in THIS repository. Create branch `{0}`,\n"
+ "make the minimal necessary changes, ensure the build passes, commit, and push the branch.",
req.IssueNumber);
FreshBranch(req, repo));

private static string CrossServiceNote(IssueWorkRequest req, WorkRepo repo)
{
Expand Down
33 changes: 29 additions & 4 deletions src/AgentOs.Web/Services/BoardTasks/BoardTaskService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,24 @@
return shortName.Length > 90 ? shortName[..90] + "…" : shortName;
}

// A fresh attempt must not reuse a branch an earlier, interrupted attempt left on the remote: its push would be
// rejected as non-fast-forward and the agent may not force-push (#15 on the lab finished its work and could not push
// it). The first free of issue-N-ai-fix, -2 … -9; null (the default name) when it can't be checked.
internal static async Task<string?> FreeBranchAsync(IPrCreationService prs, WorkspaceDescriptor repo, int issueNumber)
{
ArgumentNullException.ThrowIfNull(prs);
if (issueNumber <= 0) { return null; }
var name = WorkRepo.DefaultBranchName(issueNumber);
for (var attempt = 1; attempt <= 9; attempt++)
{
var candidate = attempt == 1 ? name : $"{name}-{attempt.ToString(System.Globalization.CultureInfo.InvariantCulture)}";
var exists = await prs.BranchExistsAsync(repo, candidate).ConfigureAwait(false);
if (exists is null) { return null; }
if (exists == false) { return attempt == 1 ? null : candidate; }
}
return $"{name}-{DateTimeOffset.UtcNow:yyyyMMddHHmm}";
}

/// <summary>The number of a pull request from its URL (…/pull/42).</summary>
public static int? PrNumber(string? url)
{
Expand Down Expand Up @@ -308,10 +326,17 @@
var issueBody = await ReadTicketBodyAsync(scope, board, tenantId, session, pat).ConfigureAwait(false);

// Ask for changes: each repo that already has a pull request continues on its branch.
var workRepos = children
.Select(c => new WorkRepo(c.Id, c.Owner, c.Repo, c.DefaultBranch,
change is not null && !string.IsNullOrEmpty(c.PrUrl) ? c.BranchName : null))
.ToList();
var workRepos = new List<WorkRepo>(children.Count);
foreach (var c in children)
{
var continuing = change is not null && !string.IsNullOrEmpty(c.PrUrl);
workRepos.Add(new WorkRepo(c.Id, c.Owner, c.Repo, c.DefaultBranch, continuing ? c.BranchName : null)
{
NewBranch = continuing ? null
: await FreeBranchAsync(prService, RepoDescriptor(board, tenantId, c.Owner, c.Repo, c.DefaultBranch, pat),
session.IssueNumber ?? 0).ConfigureAwait(false),
});
}
var issueRequest = new IssueWorkRequest(
SessionId: session.Id,
TenantId: tenantId,
Expand Down Expand Up @@ -448,7 +473,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 476 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 @@ -457,7 +482,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 485 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 @@ -500,8 +525,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 528 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 529 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 @@ -510,8 +535,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 538 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 539 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 All @@ -523,8 +548,8 @@
try
{
var pr = await prService.OpenPrAsync(result, descriptor, title, body, CancellationToken.None).ConfigureAwait(false);
await sessRepo.UpdateRepoRunResultAsync(tenantId, primary.Id, "Done", pr.Branch, pr.HtmlUrl, null).ConfigureAwait(false);

Check warning on line 551 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 552 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.Done, $"{repoLabel} — PR opened.", repoLabel, "Done", pr.HtmlUrl);
}
catch (Exception ex) when (ex is System.Net.Http.HttpRequestException or System.Text.Json.JsonException
Expand Down
14 changes: 14 additions & 0 deletions tests/AgentOs.Tests/Pipeline/IssueWorkRevisionPromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ public void FollowUp_RequestedChanges_AreFencedAsUntrusted()
user.ShouldContain("on branch `issue-4-ai-fix`");
}

// An interrupted earlier attempt left issue-4-ai-fix on the remote: the server picks a free name and the whole prompt
// uses it (pushing to the old one would need a force-push the agent may not do).
[Fact]
public void FreshAttempt_ServerPickedBranch_UsedEverywhere()
{
var repo = NewRepo with { NewBranch = "issue-4-ai-fix-2" };
foreach (var system in new[] { IssueWorkPrompt.System(Req(repo), repo), IssueWorkPrompt.SystemCli(Req(repo), repo) })
{
system.ShouldContain("git checkout -b issue-4-ai-fix-2");
system.ShouldContain("{\"branch\":\"issue-4-ai-fix-2\"");
}
IssueWorkPrompt.User(Req(repo), repo).ShouldContain("Create branch `issue-4-ai-fix-2`");
}

[Fact]
public void FollowUp_WithoutABranch_IsAFirstRun() =>
IssueWorkPrompt.SystemCli(Req(NewRepo, "change it"), NewRepo).ShouldContain("git checkout -b issue-4-ai-fix");
Expand Down
46 changes: 46 additions & 0 deletions tests/AgentOs.Tests/Web/FreeBranchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// A fresh attempt picks a branch an earlier, interrupted attempt didn't leave on the remote (#15 on the lab finished its
// work and could not push: issue-15-ai-fix already existed, and the agent may not force-push).

using System;
using System.Threading;
using System.Threading.Tasks;
using AgentOs.Domain.Workspaces;
using AgentOs.Web.Services.BoardTasks;
using NSubstitute;
using Shouldly;
using Xunit;

namespace AgentOs.Tests.Web;

public sealed class FreeBranchTests
{
private static readonly WorkspaceDescriptor Repo = new(Guid.NewGuid(), "t", SourceProviderKind.GitHub, "o", "r", null, "main", "pat");

private static IPrCreationService Remote(params string[] existing)
{
var prs = Substitute.For<IPrCreationService>();
prs.BranchExistsAsync(Arg.Any<WorkspaceDescriptor>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => Task.FromResult<bool?>(Array.IndexOf(existing, ci.ArgAt<string>(1)) >= 0));
return prs;
}

[Fact]
public async Task FreeBranch_NothingOnTheRemote_KeepsTheDefault() =>
(await BoardTaskService.FreeBranchAsync(Remote(), Repo, 15)).ShouldBeNull();

[Fact]
public async Task FreeBranch_EarlierAttemptsLeftBranches_PicksTheNextFreeOne() =>
(await BoardTaskService.FreeBranchAsync(Remote("issue-15-ai-fix", "issue-15-ai-fix-2"), Repo, 15)).ShouldBe("issue-15-ai-fix-3");

[Fact]
public async Task FreeBranch_CannotCheck_KeepsTheDefault()
{
var prs = Substitute.For<IPrCreationService>();
prs.BranchExistsAsync(Arg.Any<WorkspaceDescriptor>(), Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult<bool?>(null));
(await BoardTaskService.FreeBranchAsync(prs, Repo, 15)).ShouldBeNull();
}

[Fact]
public async Task FreeBranch_NoTicketNumber_KeepsTheDefault() =>
(await BoardTaskService.FreeBranchAsync(Remote("issue-0-ai-fix"), Repo, 0)).ShouldBeNull();
}
Loading