Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs
lacvet f173e2a63b 같은 탭 대화 전환 중에도 AX Agent 실행이 계속되도록 수정
- 실행 시작 대화를 탭별로 추적해 같은 탭에서 다른 대화로 이동하거나 새 대화를 시작해도 원래 대화에 이벤트와 완료 결과를 저장하도록 정리함
- 탭 복귀 시 진행 중인 대화를 다시 로드하고 백그라운드 실행 저장이 현재 선택 대화 ID를 덮어쓰지 않도록 세션/저장 경로를 보강함
- ChatSessionStateService와 AxAgentExecutionEngine 회귀 테스트를 추가하고 README.md, docs/DEVELOPMENT.md 이력을 갱신함
- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_conversation_background_resume\ -p:IntermediateOutputPath=obj\verify_conversation_background_resume\ (경고 0, 오류 0)
- 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter ChatSessionStateServiceTests|AxAgentExecutionEngineTests -p:OutputPath=bin\verify_conversation_background_resume_tests\ -p:IntermediateOutputPath=obj\verify_conversation_background_resume_tests\ (통과 39)
2026-04-15 19:24:40 +09:00

242 lines
11 KiB
C#

using System.Threading.Channels;
using System.Windows;
using System.Windows.Threading;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
/// <summary>
/// 에이전트 이벤트의 무거운 작업(대화 반영, 저장, 실행 이력 기록)을
/// 백그라운드 단일 리더에서 배치 처리해 UI 스레드 점유를 줄입니다.
/// </summary>
public partial class ChatWindow
{
/// <summary>백그라운드 처리 대상 에이전트 이벤트 단위입니다.</summary>
private readonly record struct AgentEventWorkItem(
AgentEvent Event,
string EventTab,
string ActiveTab,
bool ShouldRender);
private readonly Channel<AgentEventWorkItem> _agentEventChannel =
Channel.CreateUnbounded<AgentEventWorkItem>(
new UnboundedChannelOptions { SingleReader = true, AllowSynchronousContinuations = false });
private Task? _agentEventProcessorTask;
/// <summary>백그라운드 이벤트 프로세서를 시작합니다.</summary>
private void StartAgentEventProcessor()
{
_agentEventProcessorTask = Task.Run(ProcessAgentEventsAsync);
}
/// <summary>백그라운드 이벤트 프로세서를 종료합니다.</summary>
private void StopAgentEventProcessor()
{
_agentEventChannel.Writer.TryComplete();
// 종료 대기는 생략한다. 남은 정리는 GC와 종료 루틴에 맡긴다.
}
/// <summary>
/// 에이전트 이벤트를 백그라운드 큐에 추가합니다.
/// 대화 히스토리 반영과 저장은 프로세서에서 한 번만 수행합니다.
/// </summary>
private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender)
{
_agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender));
}
/// <summary>
/// 백그라운드 전용: 채널에 쌓인 이벤트를 배치로 읽어 대화 반영과 저장을 수행합니다.
/// </summary>
private async Task ProcessAgentEventsAsync()
{
var reader = _agentEventChannel.Reader;
var persistStopwatch = System.Diagnostics.Stopwatch.StartNew();
ChatConversation? pendingPersist = null;
var batch = new List<AgentEventWorkItem>(16);
try
{
while (await reader.WaitToReadAsync().ConfigureAwait(false))
{
batch.Clear();
while (reader.TryRead(out var item))
batch.Add(item);
if (batch.Count == 0)
continue;
bool anyNeedsRender = false;
bool hasTerminalEvent = false;
foreach (var work in batch)
{
var evt = work.Event;
var eventTab = work.EventTab;
var activeTab = work.ActiveTab;
try
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
{
var targetConversation = GetStreamingConversation(eventTab);
var result = _chatEngine.AppendExecutionEvent(
session, _storage, _currentConversation, activeTab, eventTab, evt, targetConversation);
// 방어: 결과 대화가 빈 대화로 교체되는 것을 방지
// EnsureCurrentConversation이 기존 대화 대신 새 빈 대화를 생성하는 경우 발생
var resultConv = result.CurrentConversation;
var currentMsgCount = _currentConversation?.Messages?.Count ?? 0;
var resultMsgCount = resultConv?.Messages?.Count ?? 0;
if (resultConv != null && resultMsgCount == 0 && currentMsgCount > 0
&& _currentConversation != null
&& string.Equals(
_currentConversation.Tab?.Trim(),
resultConv.Tab?.Trim(),
StringComparison.OrdinalIgnoreCase))
{
// 기존 대화 유지 — session에도 복원
session.CurrentConversation = _currentConversation;
LogService.Info($"[EventProc] 대화 교체 차단: 기존 msgCount={currentMsgCount}, 결과 msgCount={resultMsgCount}, convId={_currentConversation.Id?[..Math.Min(8, _currentConversation.Id?.Length ?? 0)]}");
}
else
{
_currentConversation = resultConv;
}
pendingPersist = result.UpdatedConversation;
}
}
}
catch (Exception ex)
{
LogService.Debug($"백그라운드 이벤트 처리 오류 (execution): {ex.Message}");
}
if (evt.Type == AgentEventType.Complete)
{
try
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
{
var summary = string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary;
var targetConversation = GetStreamingConversation(eventTab);
var result = _chatEngine.AppendAgentRun(
session, _storage, _currentConversation, activeTab, eventTab, evt, "completed", summary, targetConversation);
// 방어: Complete 이벤트에서도 대화 교체 보호
var completeMsgCount = result.CurrentConversation?.Messages?.Count ?? 0;
var existingMsgCount = _currentConversation?.Messages?.Count ?? 0;
if (completeMsgCount == 0 && existingMsgCount > 0 && _currentConversation != null)
{
session.CurrentConversation = _currentConversation;
}
else
{
_currentConversation = result.CurrentConversation;
}
pendingPersist = result.UpdatedConversation;
}
}
}
catch (Exception ex)
{
LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run complete): {ex.Message}");
}
hasTerminalEvent = true;
}
else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName))
{
try
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
{
var summary = string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary;
var targetConversation = GetStreamingConversation(eventTab);
var result = _chatEngine.AppendAgentRun(
session, _storage, _currentConversation, activeTab, eventTab, evt, "failed", summary, targetConversation);
_currentConversation = result.CurrentConversation;
pendingPersist = result.UpdatedConversation;
}
}
}
catch (Exception ex)
{
LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run error): {ex.Message}");
}
hasTerminalEvent = true;
}
if (work.ShouldRender)
anyNeedsRender = true;
}
if (pendingPersist != null && (hasTerminalEvent || persistStopwatch.ElapsedMilliseconds > 2000))
{
try
{
_storage.Save(pendingPersist);
var rememberTab = pendingPersist.Tab ?? "Cowork";
var session = _appState.ChatSession;
if (session?.CurrentConversation != null
&& string.Equals(session.CurrentConversation.Id, pendingPersist.Id, StringComparison.Ordinal))
{
session.RememberConversation(rememberTab, pendingPersist.Id);
}
}
catch (Exception ex)
{
LogService.Debug($"백그라운드 대화 저장 실패: {ex.Message}");
}
pendingPersist = null;
persistStopwatch.Restart();
}
if (anyNeedsRender)
{
try
{
Application.Current?.Dispatcher?.BeginInvoke(
() => ScheduleExecutionHistoryRender(autoScroll: true),
DispatcherPriority.Background);
}
catch
{
// 창 종료 중에는 무시한다.
}
}
}
}
catch (OperationCanceledException) { }
catch (ChannelClosedException) { }
catch (Exception ex)
{
LogService.Debug($"에이전트 이벤트 프로세서 종료: {ex.Message}");
}
if (pendingPersist != null)
{
try
{
_storage.Save(pendingPersist);
}
catch
{
// 종료 중 마지막 저장 실패는 무시한다.
}
}
}
}