From 8b37fc26b4e878e4520127fd70c83a340229390f Mon Sep 17 00:00:00 2001 From: Hoang Snowy Date: Fri, 23 Oct 2026 08:21:59 +0700 Subject: [PATCH] fix(autopilot): the Orchestrator's retry budget survives restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-led retry count lived in memory, so every lab update gave a stuck task two more tries (seen on #42). It now comes from the task's persisted FixRounds (#272 — every change sent back to the Coding agent, by anyone): the rules' 3 rounds plus the agent's 2, capped, so a restart grants nothing new. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB --- .../Orchestration/ProjectOrchestrator.Agent.cs | 11 +++++++---- tests/AgentOs.Tests/Web/AutopilotOrchestratorTests.cs | 10 ++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) 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); }