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()
{