diff --git a/docs/CONFIG.md b/docs/CONFIG.md index da3a836..c417bf8 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -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 diff --git a/src/AgentOs.Modules.Pipeline/Agents/AcceptanceAgent.cs b/src/AgentOs.Modules.Pipeline/Agents/AcceptanceAgent.cs index 44660e1..1289266 100644 --- a/src/AgentOs.Modules.Pipeline/Agents/AcceptanceAgent.cs +++ b/src/AgentOs.Modules.Pipeline/Agents/AcceptanceAgent.cs @@ -28,7 +28,6 @@ public sealed class AcceptanceAgent(ILlmClientFactory factory, ILogger public async Task RunAsync(AcceptanceRequest request, CancellationToken ct = default) @@ -65,7 +64,7 @@ public async Task 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, diff --git a/src/AgentOs.Modules.Pipeline/Agents/AgentProfiles.cs b/src/AgentOs.Modules.Pipeline/Agents/AgentProfiles.cs index 9f25fb8..333cae4 100644 --- a/src/AgentOs.Modules.Pipeline/Agents/AgentProfiles.cs +++ b/src/AgentOs.Modules.Pipeline/Agents/AgentProfiles.cs @@ -21,6 +21,10 @@ public interface IAgentProfiles /// The QA-loop iteration cap for the current tenant. ValueTask MaxIterationsAsync(int fallback, CancellationToken cancellationToken = default); + + /// How long one run of a Board agent may take (an Autopilot:*RunMinutes setting) for the current tenant. + ValueTask RunLimitAsync(string key, CancellationToken cancellationToken = default) => + ValueTask.FromResult(AutopilotSettings.DefaultRunLimit(key)); } /// over . @@ -81,6 +85,10 @@ public async ValueTask MaxIterationsAsync(int fallback, CancellationToken c return n >= 1 ? n : fallback; } + /// + public ValueTask 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() { diff --git a/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs b/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs index 30c18cb..00309a7 100644 --- a/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs +++ b/src/AgentOs.Modules.Pipeline/Agents/AutopilotSettings.cs @@ -76,12 +76,24 @@ public sealed class AutopilotSettings : ISettingsContributor /// How often to look at them (seconds). public const string ChecksPollSecondsKey = "Autopilot:ChecksPollSeconds"; + /// The longest one Coding (tickets) run may take on a computer (minutes) — clone, change, build, test, push. + public const string CodingRunMinutesKey = "Autopilot:CodingRunMinutes"; + + /// The longest one Reviewer run may take (minutes). + public const string ReviewRunMinutesKey = "Autopilot:ReviewRunMinutes"; + + /// The longest one Testing (tickets) run may take (minutes) — bring the app up and check every criterion. + 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; /// Each bound's key, range and default — the descriptors and the typed read share it. private static readonly (string Key, int Min, int Max, int Default)[] Bounds = @@ -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), ]; /// True when the stored value means a MAF workflow (anything but "Step by step"). @@ -124,8 +139,35 @@ public IEnumerable 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."); + } + + /// A run time limit (, …), read now and clamped to its range. + public static async ValueTask 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)); + } + + /// The run time limit an agent applies: the tenant's value through , or the built-in + /// one when there is no profile source or it gives nothing usable (zero or negative). + public static async ValueTask 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); } + /// A run time limit's built-in value. + 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); diff --git a/src/AgentOs.Modules.Pipeline/Agents/IssueWorkAgent.cs b/src/AgentOs.Modules.Pipeline/Agents/IssueWorkAgent.cs index 1b844d5..92e0897 100644 --- a/src/AgentOs.Modules.Pipeline/Agents/IssueWorkAgent.cs +++ b/src/AgentOs.Modules.Pipeline/Agents/IssueWorkAgent.cs @@ -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; @@ -248,7 +247,7 @@ private async Task 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, diff --git a/src/AgentOs.Modules.Pipeline/Agents/ReviewAgent.cs b/src/AgentOs.Modules.Pipeline/Agents/ReviewAgent.cs index 7f6c2e1..32ba32e 100644 --- a/src/AgentOs.Modules.Pipeline/Agents/ReviewAgent.cs +++ b/src/AgentOs.Modules.Pipeline/Agents/ReviewAgent.cs @@ -27,7 +27,6 @@ public sealed class ReviewAgent(ILlmClientFactory factory, ILogger 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); /// public async Task RunAsync(AcceptanceRequest request, CancellationToken ct = default) @@ -65,7 +64,7 @@ public async Task 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, diff --git a/src/AgentOs.Web/Components/Pages/AutopilotSettingsApp.razor b/src/AgentOs.Web/Components/Pages/AutopilotSettingsApp.razor index 3c3fbbb..a73681b 100644 --- a/src/AgentOs.Web/Components/Pages/AutopilotSettingsApp.razor +++ b/src/AgentOs.Web/Components/Pages/AutopilotSettingsApp.razor @@ -38,6 +38,11 @@ else + +

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.

+ +
+
  • Merging. With Build and merge, 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.
  • @@ -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; diff --git a/tests/AgentOs.E2E.Tests/AgentOsUi/AutopilotSettingsTests.cs b/tests/AgentOs.E2E.Tests/AgentOsUi/AutopilotSettingsTests.cs index e9bf65e..f26fda6 100644 --- a/tests/AgentOs.E2E.Tests/AgentOsUi/AutopilotSettingsTests.cs +++ b/tests/AgentOs.E2E.Tests/AgentOsUi/AutopilotSettingsTests.cs @@ -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(); diff --git a/tests/AgentOs.Tests/Agents/BoardAgentRegistryTests.cs b/tests/AgentOs.Tests/Agents/BoardAgentRegistryTests.cs index 0009c78..eaceaa8 100644 --- a/tests/AgentOs.Tests/Agents/BoardAgentRegistryTests.cs +++ b/tests/AgentOs.Tests/Agents/BoardAgentRegistryTests.cs @@ -64,7 +64,7 @@ private static (ILlmClientFactory Factory, List 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.Instance); private static IBudgetGuard OverBudget() diff --git a/tests/AgentOs.Tests/Agents/ComputersDefaultModelTests.cs b/tests/AgentOs.Tests/Agents/ComputersDefaultModelTests.cs index d305da0..2c708c7 100644 --- a/tests/AgentOs.Tests/Agents/ComputersDefaultModelTests.cs +++ b/tests/AgentOs.Tests/Agents/ComputersDefaultModelTests.cs @@ -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.Instance); // A factory whose every client is `client`. diff --git a/tests/AgentOs.Tests/Sessions/IssueWorkAgentTests.cs b/tests/AgentOs.Tests/Sessions/IssueWorkAgentTests.cs index d720709..56c5f1e 100644 --- a/tests/AgentOs.Tests/Sessions/IssueWorkAgentTests.cs +++ b/tests/AgentOs.Tests/Sessions/IssueWorkAgentTests.cs @@ -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(); + llm.SendAsync(Arg.Do(r => captured = r), Arg.Any()) + .Returns(AgentTestHelpers.StubResponse("""{"branch":"issue-42-ai-fix","summary":"Done."}""")); + var agent = new IssueWorkAgent(AgentTestHelpers.FactoryReturning(llm), AgentTestHelpers.OptionsWith(new AgentsOptions()), + NullLogger.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() { @@ -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 ResolveAsync(string role, AgentOptions fallback, CancellationToken cancellationToken = default) => ValueTask.FromResult(fallback); + + public ValueTask MaxIterationsAsync(int fallback, CancellationToken cancellationToken = default) => ValueTask.FromResult(fallback); + + public ValueTask RunLimitAsync(string key, CancellationToken cancellationToken = default) => + ValueTask.FromResult(key == AutopilotSettings.CodingRunMinutesKey ? limit : TimeSpan.Zero); + } } diff --git a/tests/AgentOs.Tests/Web/AutopilotConfigurationTests.cs b/tests/AgentOs.Tests/Web/AutopilotConfigurationTests.cs index cc85aca..67fa500 100644 --- a/tests/AgentOs.Tests/Web/AutopilotConfigurationTests.cs +++ b/tests/AgentOs.Tests/Web/AutopilotConfigurationTests.cs @@ -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) { @@ -64,6 +67,25 @@ public ValueTask 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(async () => await AutopilotSettings.ReadRunLimitAsync(settings, "t1", AutopilotSettings.MaxParallelKey + "x")); + } + [Fact] public async Task ReadLimits_Unset_IsTheDefault() {