From fe0ff05504424b6039fd5d3bcde400a3abc3752c Mon Sep 17 00:00:00 2001 From: Hoang Snowy Date: Sat, 24 Oct 2026 22:01:49 +0700 Subject: [PATCH] fix(autopilot): a ticket the Orchestrator closes on its own doesn't thank anyone for an answer VNChess #1 (2026-10-24): the epic had a stuck task, so the Orchestrator agent's close went through the person-answered path and posted "after your answer" although nobody had answered. Closing without an answer now uses the agent's own footer (shared with epics). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB --- .../Orchestration/ProjectOrchestrator.Agent.cs | 14 +++++++++++--- tests/AgentOs.Tests/Web/NeedsPersonBlockTests.cs | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs index dfb3693..8ca3281 100644 --- a/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs +++ b/src/AgentOs.Web/Services/Orchestration/ProjectOrchestrator.Agent.cs @@ -127,8 +127,7 @@ await CommentOnTicketAsync(board, pat, sessions.First(s => s.Id == task.SessionI var epic = tickets.FirstOrDefault(t => t.Number == close.IssueNumber); if (epic is { RepoOwner: { } eo, RepoName: { } en } && await sp.GetRequiredService().CloseAsync( BoardTaskService.RepoDescriptor(board, tenant, eo, en, "main", pat), close.IssueNumber, - $"Closing — {close.Reason}\n\nClosed by the AgentOS.NET Orchestrator agent: every piece of this epic is delivered. Reopen it if anything is missing.\n", - ct).ConfigureAwait(false)) + ClosedByAgent(close.Reason), ct).ConfigureAwait(false)) { retried.Add(close.IssueNumber); log.Add(tenant, board.Id, $"Closed epic #{close.IssueNumber}: {close.Reason}"); @@ -137,7 +136,12 @@ await CommentOnTicketAsync(board, pat, sessions.First(s => s.Id == task.SessionI } var task = stuck.First(s => s.IssueNumber == close.IssueNumber); var session = sessions.First(s => s.Id == task.SessionId); - if (!await CloseTicketAsync(board, pat, session, PersonAnswers.Acknowledgement($"closing #{close.IssueNumber}: {close.Reason}"), sp, ct).ConfigureAwait(false)) + // Thank the person only when they answered; a ticket the agent closes on its own (an epic with a stuck task, + // its scope delivered) says so, like an epic without one. + var closing = task.PersonAnswer is null + ? ClosedByAgent(close.Reason) + : PersonAnswers.Acknowledgement($"closing #{close.IssueNumber}: {close.Reason}"); + if (!await CloseTicketAsync(board, pat, session, closing, sp, ct).ConfigureAwait(false)) { continue; } @@ -161,6 +165,10 @@ await CommentOnTicketAsync(board, pat, sessions.First(s => s.Id == task.SessionI return picked; } + /// The comment on a ticket the Orchestrator agent closes on its own (nobody was asked): its reason, and how to undo it. + internal static string ClosedByAgent(string reason) => + $"Closing — {reason}\n\nClosed by the AgentOS.NET Orchestrator agent: everything this ticket covers is delivered. Reopen it if anything is missing.\n"; + private readonly ConcurrentDictionary<(Guid Board, int Issue), (DateTimeOffset At, string Body)> _epicBodies = new(); // Open umbrella tickets (labelled for a person) with their text — read at most every 10 minutes each. diff --git a/tests/AgentOs.Tests/Web/NeedsPersonBlockTests.cs b/tests/AgentOs.Tests/Web/NeedsPersonBlockTests.cs index f771916..3b0de8e 100644 --- a/tests/AgentOs.Tests/Web/NeedsPersonBlockTests.cs +++ b/tests/AgentOs.Tests/Web/NeedsPersonBlockTests.cs @@ -36,3 +36,18 @@ public void HoldsAProposedChange_MergedFinishedOrInFlight_IsLeftAlone(string? st ProjectOrchestrator.HoldsAProposedChange(status).ShouldBeFalse(); } } + +public sealed class ClosedByAgentTests +{ + // VNChess #1 (2026-10-24): an epic with a stuck task was closed with "Posted … after your answer" although nobody + // had answered. + [Fact] + public void ClosedByAgent_SaysTheAgentClosedIt_NeverThanksForAnAnswer() + { + var text = AgentOs.Web.Services.Orchestration.ProjectOrchestrator.ClosedByAgent("every M1 item is merged."); + + text.ShouldStartWith("Closing — every M1 item is merged."); + text.ShouldContain("Reopen it if anything is missing"); + text.ShouldNotContain("your answer"); + } +}