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
3 changes: 3 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ pass / call, so a change applies within a minute, no restart. The Board's Autopi
| `Autopilot:MaxAgentRetries` | `2` | 0–5 | Extra guided retries the Orchestrator agent may give a stuck task after the fix rounds are used up. |
| `Autopilot:ChecksTimeoutMinutes` | `60` | 5–240 | How long a ticket's MAF workflow waits for a pull request's checks before handing it back to the step-by-step passes. |
| `Autopilot:ChecksPollSeconds` | `60` | 15–600 | How often it looks at them. |
| `Autopilot:CodingRunMinutes` | `60` | 10–240 | The longest one Coding (tickets) run may take on a computer — clone, change, build, test, push (was a fixed 20 minutes). |
| `Autopilot:ReviewRunMinutes` | `30` | 5–120 | The longest one Reviewer run may take (was a fixed 20). |
| `Autopilot:TestingRunMinutes` | `45` | 10–180 | The longest one Testing (tickets) run may take (was a fixed 30). |

Fixed rules (not settings): *Build and merge* merges a change only when its checks are green and its ticket flow's
gates passed — by default the Reviewer agent approved it and the Testing agent passed it, which needs the member's
Expand Down
3 changes: 1 addition & 2 deletions src/AgentOs.Modules.Pipeline/Agents/AcceptanceAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ public sealed class AcceptanceAgent(ILlmClientFactory factory, ILogger<Acceptanc
IBudgetGuard? budgetGuard = null, IMemoryStore? memory = null) : IAcceptanceAgent
{
// Building, starting a stack and running end-to-end specs takes longer than writing the change.
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(30);

/// <inheritdoc />
public async Task<AcceptanceVerdict> RunAsync(AcceptanceRequest request, CancellationToken ct = default)
Expand Down Expand Up @@ -65,7 +64,7 @@ public async Task<AcceptanceVerdict> RunAsync(AcceptanceRequest request, Cancell
Model: profile.Model,
Temperature: profile.Temperature,
MaxTokens: profile.MaxTokens,
Timeout: RunTimeout,
Timeout: await AutopilotSettings.RunLimitForAsync(profiles, AutopilotSettings.TestingRunMinutesKey, ct).ConfigureAwait(false),
Cli: request.CliProfile)
{
AgentRole = AgentRoles.Acceptance,
Expand Down
8 changes: 8 additions & 0 deletions src/AgentOs.Modules.Pipeline/Agents/AgentProfiles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public interface IAgentProfiles

/// <summary>The QA-loop iteration cap for the current tenant.</summary>
ValueTask<int> MaxIterationsAsync(int fallback, CancellationToken cancellationToken = default);

/// <summary>How long one run of a Board agent may take (an <c>Autopilot:*RunMinutes</c> setting) for the current tenant.</summary>
ValueTask<TimeSpan> RunLimitAsync(string key, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(AutopilotSettings.DefaultRunLimit(key));
}

/// <summary><see cref="IAgentProfiles"/> over <see cref="ISettings"/>.</summary>
Expand Down Expand Up @@ -81,6 +85,10 @@ public async ValueTask<int> MaxIterationsAsync(int fallback, CancellationToken c
return n >= 1 ? n : fallback;
}

/// <inheritdoc />
public ValueTask<TimeSpan> RunLimitAsync(string key, CancellationToken cancellationToken = default) =>
AutopilotSettings.ReadRunLimitAsync(_settings, CurrentTenant(), key, cancellationToken);

// Ambient identity first (Blazor circuits and background runs push it); else the request's tenant context.
private string? CurrentTenant()
{
Expand Down
42 changes: 42 additions & 0 deletions src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,24 @@ public sealed class AutopilotSettings : ISettingsContributor
/// <summary>How often to look at them (seconds).</summary>
public const string ChecksPollSecondsKey = "Autopilot:ChecksPollSeconds";

/// <summary>The longest one Coding (tickets) run may take on a computer (minutes) — clone, change, build, test, push.</summary>
public const string CodingRunMinutesKey = "Autopilot:CodingRunMinutes";

/// <summary>The longest one Reviewer run may take (minutes).</summary>
public const string ReviewRunMinutesKey = "Autopilot:ReviewRunMinutes";

/// <summary>The longest one Testing (tickets) run may take (minutes) — bring the app up and check every criterion.</summary>
public const string TestingRunMinutesKey = "Autopilot:TestingRunMinutes";

internal const int DefaultMaxParallel = 2;
internal const int DefaultMaxParallelTests = 1;
internal const int DefaultMaxFixRounds = 3;
internal const int DefaultMaxAgentRetries = 2;
internal const int DefaultChecksTimeoutMinutes = 60;
internal const int DefaultChecksPollSeconds = 60;
internal const int DefaultCodingRunMinutes = 60;
internal const int DefaultReviewRunMinutes = 30;
internal const int DefaultTestingRunMinutes = 45;

/// <summary>Each bound's key, range and default — the descriptors and the typed read share it.</summary>
private static readonly (string Key, int Min, int Max, int Default)[] Bounds =
Expand All @@ -92,6 +104,9 @@ private static readonly (string Key, int Min, int Max, int Default)[] Bounds =
(MaxAgentRetriesKey, 0, 5, DefaultMaxAgentRetries),
(ChecksTimeoutMinutesKey, 5, 240, DefaultChecksTimeoutMinutes),
(ChecksPollSecondsKey, 15, 600, DefaultChecksPollSeconds),
(CodingRunMinutesKey, 10, 240, DefaultCodingRunMinutes),
(ReviewRunMinutesKey, 5, 120, DefaultReviewRunMinutes),
(TestingRunMinutesKey, 10, 180, DefaultTestingRunMinutes),
];

/// <summary>True when the stored value means a MAF workflow (anything but "Step by step").</summary>
Expand Down Expand Up @@ -124,8 +139,35 @@ public IEnumerable<SettingDescriptor> Describe()
"How long a ticket's MAF workflow waits for the pull request's checks before handing it back to Autopilot's step-by-step passes.");
yield return Bound(ChecksPollSecondsKey, "Look at checks every (seconds)",
"How often the MAF workflow looks at the pull request's checks while it waits.");
yield return Bound(CodingRunMinutesKey, "Coding run time limit (minutes)",
"The longest one Coding agent run may take on your computer before it is stopped: clone, change, build, test and push. Big tickets (migrations, upgrades) need more.");
yield return Bound(ReviewRunMinutesKey, "Review run time limit (minutes)",
"The longest one Reviewer agent run may take.");
yield return Bound(TestingRunMinutesKey, "Testing run time limit (minutes)",
"The longest one Testing agent run may take: bring the app up and check every acceptance criterion.");
}

/// <summary>A run time limit (<see cref="CodingRunMinutesKey"/>, …), read now and clamped to its range.</summary>
public static async ValueTask<TimeSpan> ReadRunLimitAsync(ISettings settings, string? tenantId, string key, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(settings);
var b = Array.Find(Bounds, x => x.Key == key);
if (b.Key is null) { throw new ArgumentException($"Not a run time limit: {key}", nameof(key)); }
var minutes = await settings.GetIntAsync(key, tenantId, b.Default, ct).ConfigureAwait(false);
return TimeSpan.FromMinutes(Math.Clamp(minutes, b.Min, b.Max));
}

/// <summary>The run time limit an agent applies: the tenant's value through <paramref name="profiles"/>, or the built-in
/// one when there is no profile source or it gives nothing usable (zero or negative).</summary>
public static async ValueTask<TimeSpan> RunLimitForAsync(IAgentProfiles? profiles, string key, CancellationToken ct = default)
{
var limit = profiles is null ? TimeSpan.Zero : await profiles.RunLimitAsync(key, ct).ConfigureAwait(false);
return limit > TimeSpan.Zero ? limit : DefaultRunLimit(key);
}

/// <summary>A run time limit's built-in value.</summary>
public static TimeSpan DefaultRunLimit(string key) => TimeSpan.FromMinutes(Array.Find(Bounds, x => x.Key == key).Default);

private static SettingDescriptor Bound(string key, string label, string help)
{
var b = Array.Find(Bounds, x => x.Key == key);
Expand Down
3 changes: 1 addition & 2 deletions src/AgentOs.Modules.Pipeline/Agents/IssueWorkAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ public sealed class IssueWorkAgent : IIssueWorkAgent
{
// A single agentic CLI run on the member's machine (clone → build → test → push) far outlasts the
// RemoteAgent default 120s dispatch cap, so CLI-mode runs get a generous deadline.
private static readonly TimeSpan CliRunTimeout = TimeSpan.FromMinutes(20);

private readonly ILlmClientFactory _factory;
private readonly AgentOptions _agentOpts;
Expand Down Expand Up @@ -248,7 +247,7 @@ private async Task<RepoWorkOutcome> RunOneAsync(ILlmClient llm, AgentOptions opt
Temperature: opts.Temperature,
MaxTokens: opts.MaxTokens,
Tools: [],
Timeout: CliRunTimeout,
Timeout: await AutopilotSettings.RunLimitForAsync(_profiles, AutopilotSettings.CodingRunMinutesKey, ct).ConfigureAwait(false),
Cli: request.CliProfile)
{
AgentRole = AgentRoles.IssueWork,
Expand Down
3 changes: 1 addition & 2 deletions src/AgentOs.Modules.Pipeline/Agents/ReviewAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ public sealed class ReviewAgent(ILlmClientFactory factory, ILogger<ReviewAgent>
IPromptOverrides? prompts = null, IBudgetGuard? budgetGuard = null) : IReviewAgent
{
// Reading a diff in context is quicker than testing it; a build fits too.
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(20);

/// <inheritdoc />
public async Task<ReviewVerdict> RunAsync(AcceptanceRequest request, CancellationToken ct = default)
Expand Down Expand Up @@ -65,7 +64,7 @@ public async Task<ReviewVerdict> RunAsync(AcceptanceRequest request, Cancellatio
Model: profile.Model,
Temperature: profile.Temperature,
MaxTokens: profile.MaxTokens,
Timeout: RunTimeout,
Timeout: await AutopilotSettings.RunLimitForAsync(profiles, AutopilotSettings.ReviewRunMinutesKey, ct).ConfigureAwait(false),
Cli: request.CliProfile)
{
AgentRole = AgentRoles.Reviewer,
Expand Down
8 changes: 8 additions & 0 deletions src/AgentOs.Web/Components/Pages/AutopilotSettingsApp.razor
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ else
<SettingsFields Area="Autopilot" TenantId="@_tenantId" Actor="@_actor" Keys="@CheckKeys" />
</Panel>

<Panel Title="Run time limits">
<p class="muted platform-hint">How long one agent run may take before it is stopped. A run that is cut short loses its work, so give big tickets (migrations, upgrades) enough time.</p>
<SettingsFields Area="Autopilot" TenantId="@_tenantId" Actor="@_actor" Keys="@RunKeys" />
</Panel>

<Panel Title="Rules that always apply">
<ul class="autopilot-rules" aria-label="Autopilot's fixed rules">
<li><strong>Merging.</strong> With <em>Build and merge</em>, a change is merged only when its checks are green and its ticket flow's gates passed — by default the Reviewer agent approved it and the Testing agent passed it against the ticket's acceptance criteria, which needs a connected computer.</li>
Expand All @@ -63,6 +68,9 @@ else

private static readonly string[] CheckKeys = [AutopilotSettings.ChecksTimeoutMinutesKey, AutopilotSettings.ChecksPollSecondsKey];

private static readonly string[] RunKeys =
[AutopilotSettings.CodingRunMinutesKey, AutopilotSettings.ReviewRunMinutesKey, AutopilotSettings.TestingRunMinutesKey];

private bool _resolved;
private bool _isAdmin;
private string _tenantId = string.Empty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public async Task Autopilot_ShowsEverySetting_AndTheRealMergeRule()
{
"Decided by", "Each ticket runs as", "Tasks at once", "Acceptance tests at once", "Automatic fix rounds",
"Orchestrator retries", "Wait for checks (minutes)", "Look at checks every (seconds)",
"Coding run time limit (minutes)", "Review run time limit (minutes)", "Testing run time limit (minutes)",
})
{
await Assertions.Expect(win.Locator(".prefs-label strong").GetByText(label, new() { Exact = true })).ToBeVisibleAsync();
Expand Down
2 changes: 1 addition & 1 deletion tests/AgentOs.Tests/Agents/BoardAgentRegistryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ private static (ILlmClientFactory Factory, List<Call> Calls) Recording(string re
}

private static SettingsService Settings() => new(
new SettingsCatalog([new LlmSettings(), new AgentSettings()]), new InMemoryAppConfigStore(),
new SettingsCatalog([new LlmSettings(), new AgentSettings(), new AutopilotSettings()]), new InMemoryAppConfigStore(),
new ConfigurationBuilder().Build(), new NullSettingsAuditSink(), NullLogger<SettingsService>.Instance);

private static IBudgetGuard OverBudget()
Expand Down
2 changes: 1 addition & 1 deletion tests/AgentOs.Tests/Agents/ComputersDefaultModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public sealed class ComputersDefaultModelTests
private const string Approve = """{"verdict":"approve","findings":[],"summary":"ok"}""";

private static SettingsService Settings(InMemoryAppConfigStore? store = null, IConfiguration? config = null) => new(
new SettingsCatalog([new LlmSettings(), new AgentSettings()]), store ?? new InMemoryAppConfigStore(),
new SettingsCatalog([new LlmSettings(), new AgentSettings(), new AutopilotSettings()]), store ?? new InMemoryAppConfigStore(),
config ?? new ConfigurationBuilder().Build(), new NullSettingsAuditSink(), NullLogger<SettingsService>.Instance);

// A factory whose every client is `client`.
Expand Down
26 changes: 26 additions & 0 deletions tests/AgentOs.Tests/Sessions/IssueWorkAgentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,21 @@ public async Task RunAsync_RunOnMachine_ForwardsCliProfileToLlmRequest()
captured.Autonomy.ShouldBe(WorkspaceAutonomy.Scoped); // clone/edit/push in a per-run directory
}

[Fact]
public async Task RunAsync_RunOnMachine_TimeoutIsTheTenantsCodingRunLimit()
{
LlmRequest? captured = null;
var llm = Substitute.For<ILlmClient>();
llm.SendAsync(Arg.Do<LlmRequest>(r => captured = r), Arg.Any<CancellationToken>())
.Returns(AgentTestHelpers.StubResponse("""{"branch":"issue-42-ai-fix","summary":"Done."}"""));
var agent = new IssueWorkAgent(AgentTestHelpers.FactoryReturning(llm), AgentTestHelpers.OptionsWith(new AgentsOptions()),
NullLogger<IssueWorkAgent>.Instance, profiles: new RunLimitProfiles(TimeSpan.FromMinutes(75)));

await agent.RunAsync(MakeRequest(42) with { ProviderOverride = "RemoteAgent" });

captured.ShouldNotBeNull().Timeout.ShouldBe(TimeSpan.FromMinutes(75));
}

[Fact]
public async Task RunAsync_RunOnMachine_UsesCliPromptWithNoServerToolsAndLongTimeout()
{
Expand Down Expand Up @@ -419,4 +434,15 @@ public async Task RunAsync_PromptContainsIssueNumberAndRepo()
captured.UserPrompt.ShouldContain("99");
captured.UserPrompt.ShouldContain("acme/my-service");
}

// The tenant's Settings › Autopilot run limit, nothing else.
private sealed class RunLimitProfiles(TimeSpan limit) : IAgentProfiles
{
public ValueTask<AgentOptions> ResolveAsync(string role, AgentOptions fallback, CancellationToken cancellationToken = default) => ValueTask.FromResult(fallback);

public ValueTask<int> MaxIterationsAsync(int fallback, CancellationToken cancellationToken = default) => ValueTask.FromResult(fallback);

public ValueTask<TimeSpan> RunLimitAsync(string key, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(key == AutopilotSettings.CodingRunMinutesKey ? limit : TimeSpan.Zero);
}
}
22 changes: 22 additions & 0 deletions tests/AgentOs.Tests/Web/AutopilotConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public void Describe_EveryBoundIsAWholeNumberInARange_DefaultingToTodaysValues()
(AutopilotSettings.MaxAgentRetriesKey, "2", 0, 5),
(AutopilotSettings.ChecksTimeoutMinutesKey, "60", 5, 240),
(AutopilotSettings.ChecksPollSecondsKey, "60", 15, 600),
(AutopilotSettings.CodingRunMinutesKey, "60", 10, 240),
(AutopilotSettings.ReviewRunMinutesKey, "30", 5, 120),
(AutopilotSettings.TestingRunMinutesKey, "45", 10, 180),
];
foreach (var (key, def, min, max) in expected)
{
Expand Down Expand Up @@ -64,6 +67,25 @@ public ValueTask<SettingWriteResult> SetAsync(string key, string? value, Setting
CancellationToken cancellationToken = default) => throw new NotSupportedException();
}

[Fact]
public async Task ReadRunLimit_UnsetIsTheDefault_AndAValueIsClampedToItsRange()
{
// VNChess #102 (2026-10-24): a migration ticket was cut off by the fixed 20-minute Coding run cap and lost its work.
(await AutopilotSettings.ReadRunLimitAsync(new FakeSettings(_ => null), "t1", AutopilotSettings.CodingRunMinutesKey))
.ShouldBe(TimeSpan.FromMinutes(60));
var settings = new FakeSettings(key => key switch
{
AutopilotSettings.CodingRunMinutesKey => "90",
AutopilotSettings.ReviewRunMinutesKey => "999",
AutopilotSettings.TestingRunMinutesKey => "1",
_ => null,
});
(await AutopilotSettings.ReadRunLimitAsync(settings, "t1", AutopilotSettings.CodingRunMinutesKey)).ShouldBe(TimeSpan.FromMinutes(90));
(await AutopilotSettings.ReadRunLimitAsync(settings, "t1", AutopilotSettings.ReviewRunMinutesKey)).ShouldBe(TimeSpan.FromMinutes(120));
(await AutopilotSettings.ReadRunLimitAsync(settings, "t1", AutopilotSettings.TestingRunMinutesKey)).ShouldBe(TimeSpan.FromMinutes(10));
await Should.ThrowAsync<ArgumentException>(async () => await AutopilotSettings.ReadRunLimitAsync(settings, "t1", AutopilotSettings.MaxParallelKey + "x"));
}

[Fact]
public async Task ReadLimits_Unset_IsTheDefault()
{
Expand Down
Loading