diff --git a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs
index ef55200..ac12d62 100644
--- a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs
+++ b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs
@@ -28,8 +28,12 @@ public sealed partial class ProjectOrchestrator
/// Agent-led retries per stuck task (beyond the rules' fix rounds).
internal const int MaxAgentRetries = 2;
+ /// The retries the agent may still spend on a task: its persisted fix rounds (every change sent back to the
+ /// Coding agent, by anyone) count against the rules' rounds plus the agent's own — so a restart grants nothing new.
+ internal static int RetriesLeft(int fixRounds) =>
+ Math.Clamp(AutopilotPlanner.MaxFixRounds + MaxAgentRetries - fixRounds, 0, MaxAgentRetries);
+
private readonly ConcurrentDictionary _lastSignature = new();
- private readonly ConcurrentDictionary _agentRetries = new();
private readonly ConcurrentDictionary _asked = new();
private readonly ConcurrentDictionary _agentDown = new();
@@ -96,7 +100,6 @@ private async Task> DecideAsync(WorkspaceEntity b
var task = stuck.First(s => s.IssueNumber == retry.IssueNumber);
var started = await tasks.StartAsync(tenant, task.SessionId, changeRequest: retry.Guidance, ct: ct).ConfigureAwait(false);
if (started != TaskStart.Started) { continue; }
- _agentRetries.AddOrUpdate(task.SessionId, 1, (_, n) => n + 1);
_fixRounds.TryRemove(task.SessionId, out _);
retried.Add(task.IssueNumber);
running++;
@@ -227,14 +230,14 @@ private List StuckTasks(IReadOnlyList ticket
var why = string.Join(" | ", repos[s.Id].Select(r => r.Error).Prepend(s.Error).Where(e => !string.IsNullOrWhiteSpace(e)).Distinct());
stuck.Add(new OrchestratorStuckTask(s.Id, issue, titles.GetValueOrDefault(issue, s.Title),
$"{(s.Status == "Failed" ? "Couldn't finish" : "Waiting for a person")}: {(why.Length == 0 ? "no reason recorded" : why)}",
- MaxAgentRetries - _agentRetries.GetValueOrDefault(s.Id)));
+ RetriesLeft(s.FixRounds)));
}
foreach (var n in candidates.OfType().Where(n => stuck.All(s => s.SessionId != n.SessionId)))
{
var s = sessions.First(x => x.Id == n.SessionId);
var findings = string.Join(" | ", repos[s.Id].Select(r => r.Error).Where(e => !string.IsNullOrWhiteSpace(e)).Distinct());
stuck.Add(new OrchestratorStuckTask(s.Id, n.IssueNumber, titles.GetValueOrDefault(n.IssueNumber, s.Title),
- $"{n.Reason} {findings}".Trim(), MaxAgentRetries - _agentRetries.GetValueOrDefault(s.Id)));
+ $"{n.Reason} {findings}".Trim(), RetriesLeft(s.FixRounds)));
}
return stuck;
}
diff --git a/tests/AgentOs.Tests/Web/AutopilotOrchestratorTests.cs b/tests/AgentOs.Tests/Web/AutopilotOrchestratorTests.cs
index 8a93916..21a847c 100644
--- a/tests/AgentOs.Tests/Web/AutopilotOrchestratorTests.cs
+++ b/tests/AgentOs.Tests/Web/AutopilotOrchestratorTests.cs
@@ -111,4 +111,14 @@ public void Signature_IsOrderIndependent_AndChangesWithTheState()
ProjectOrchestrator.Signature(["y", "x"], ["15:s:1"], 2).ShouldNotBe(a);
ProjectOrchestrator.Signature(["y", "x"], ["15:s:2"], 1).ShouldNotBe(a);
}
+
+ // The retry budget comes from the task's persisted fix rounds — a restart of the server grants nothing new.
+ [Theory]
+ [InlineData(0, 2)]
+ [InlineData(3, 2)]
+ [InlineData(4, 1)]
+ [InlineData(5, 0)]
+ [InlineData(9, 0)]
+ public void RetriesLeft_ComesFromPersistedFixRounds(int fixRounds, int expected) =>
+ ProjectOrchestrator.RetriesLeft(fixRounds).ShouldBe(expected);
}