Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions PolyPilot/Components/Pages/Dashboard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,9 @@
completedSessions.Add(sessionName);
streamingBySession.Remove(sessionName);
activityBySession.Remove(sessionName);
// Refresh sessions list immediately — RefreshState may have been throttled
// when OnStateChanged fired just before OnSessionComplete
sessions = CopilotService.GetAllSessions().ToList();
ScheduleRender();
_ = Task.Delay(10000).ContinueWith(_ =>
{
Expand Down
68 changes: 62 additions & 6 deletions PolyPilot/Services/CopilotService.Events.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,12 +199,47 @@ private void HandleSessionEvent(SessionState state, SessionEvent evt)
state.HasReceivedEventsSinceResume = true;
Interlocked.Exchange(ref state.LastEventAtTicks, DateTime.UtcNow.Ticks);
var sessionName = state.Info.Name;
var isCurrentState = _sessions.TryGetValue(sessionName, out var current) && ReferenceEquals(current, state);

// Log critical lifecycle events and detect orphaned handlers
if (evt is SessionIdleEvent or AssistantTurnEndEvent or SessionErrorEvent)
{
Debug($"[EVT] '{sessionName}' received {evt.GetType().Name} " +
$"(IsProcessing={state.Info.IsProcessing}, isCurrentState={isCurrentState}, " +
$"thread={Environment.CurrentManagedThreadId})");
}

// Warn if receiving events on an orphaned (replaced) state object.
// We don't early-return here: both old and new SessionState share the same Info object
// (reconnect copies Info to newState), so CompleteResponse on the orphaned state still
// correctly clears IsProcessing on the live session's shared Info.
if (!isCurrentState)
{
Debug($"[EVT-WARN] '{sessionName}' event {evt.GetType().Name} delivered to ORPHANED state " +
$"(not in _sessions). This handler should have been detached.");
}

void Invoke(Action action)
{
if (_syncContext != null)
_syncContext.Post(_ => action(), null);
{
_syncContext.Post(_ =>
{
try { action(); }
catch (Exception ex)
{
Debug($"[EVT-ERR] '{sessionName}' SyncContext.Post callback threw: {ex}");
}
}, null);
}
else
action();
{
try { action(); }
catch (Exception ex)
{
Debug($"[EVT-ERR] '{sessionName}' inline callback threw: {ex}");
}
}
}

switch (evt)
Expand Down Expand Up @@ -324,7 +359,11 @@ void Invoke(Action action)
break;

case AssistantTurnEndEvent:
CompleteReasoningMessages(state, sessionName);
try { CompleteReasoningMessages(state, sessionName); }
catch (Exception ex)
{
Debug($"[EVT-ERR] '{sessionName}' CompleteReasoningMessages threw in TurnEnd: {ex}");
}
Invoke(() =>
{
OnTurnEnd?.Invoke(sessionName);
Expand All @@ -333,8 +372,18 @@ void Invoke(Action action)
break;

case SessionIdleEvent:
CompleteReasoningMessages(state, sessionName);
Invoke(() => CompleteResponse(state));
try { CompleteReasoningMessages(state, sessionName); }
catch (Exception ex)
{
Debug($"[EVT-ERR] '{sessionName}' CompleteReasoningMessages threw before CompleteResponse: {ex}");
}
Invoke(() =>
{
Debug($"[IDLE] '{sessionName}' CompleteResponse dispatched " +
$"(syncCtx={(_syncContext != null ? "UI" : "inline")}, " +
$"IsProcessing={state.Info.IsProcessing}, thread={Environment.CurrentManagedThreadId})");
CompleteResponse(state);
});
// Refresh git branch — agent may have switched branches
state.Info.GitBranch = GetGitBranch(state.Info.WorkingDirectory);
// Send notification when agent finishes
Expand Down Expand Up @@ -556,7 +605,14 @@ private void FlushCurrentResponse(SessionState state)

private void CompleteResponse(SessionState state)
{
if (!state.Info.IsProcessing) return; // Already completed (e.g. timeout)
if (!state.Info.IsProcessing)
{
Debug($"[COMPLETE] '{state.Info.Name}' CompleteResponse skipped — IsProcessing already false");
return; // Already completed (e.g. timeout)
}

Debug($"[COMPLETE] '{state.Info.Name}' CompleteResponse executing " +
$"(responseLen={state.CurrentResponse.Length}, thread={Environment.CurrentManagedThreadId})");

CancelProcessingWatchdog(state);
var response = state.CurrentResponse.ToString();
Expand Down
45 changes: 43 additions & 2 deletions PolyPilot/Services/CopilotService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
private readonly ConcurrentDictionary<string, byte> _closedSessionIds = new();
// Image paths queued alongside messages when session is busy (keyed by session name, list per queued message)
private readonly ConcurrentDictionary<string, List<List<string>>> _queuedImagePaths = new();
private static readonly object _diagnosticLogLock = new();
private readonly IChatDatabase _chatDb;
private readonly IServerManager _serverManager;
private readonly IWsBridgeClient _bridgeClient;
Expand Down Expand Up @@ -210,14 +211,51 @@
LastDebugMessage = message;
Console.WriteLine($"[DEBUG] {message}");
OnDebug?.Invoke(message);

// Persist lifecycle diagnostics to file for post-mortem analysis (DEBUG builds only)
#if DEBUG
if (message.StartsWith("[EVT") || message.StartsWith("[IDLE") ||
message.StartsWith("[COMPLETE") || message.StartsWith("[SEND") ||
message.StartsWith("[RECONNECT") || message.StartsWith("[UI-ERR") ||
message.Contains("watchdog"))
{
try
{
lock (_diagnosticLogLock)
{
var logPath = Path.Combine(PolyPilotBaseDir, "event-diagnostics.log");
// Rotate at 10 MB to prevent unbounded growth
var fi = new FileInfo(logPath);
if (fi.Exists && fi.Length > 10 * 1024 * 1024)
try { File.Delete(logPath); } catch { }
File.AppendAllText(logPath,
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} {message}{Environment.NewLine}");
}
}
catch { /* Don't let logging failures cascade */ }
}
#endif
}

private void InvokeOnUI(Action action)
{
if (_syncContext != null)
_syncContext.Post(_ => action(), null);
_syncContext.Post(_ =>
{
try { action(); }
catch (Exception ex)
{
Debug($"[UI-ERR] InvokeOnUI callback threw: {ex}");
}
}, null);
else
action();
{
try { action(); }
catch (Exception ex)
{
Debug($"[UI-ERR] InvokeOnUI inline callback threw: {ex}");
}
}
}

public async Task InitializeAsync(CancellationToken cancellationToken = default)
Expand Down Expand Up @@ -988,7 +1026,7 @@
{
var config = new McpLocalServerConfig();
if (element.TryGetProperty("command", out var cmd))
config.Command = cmd.GetString();

Check warning on line 1029 in PolyPilot/Services/CopilotService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Possible null reference assignment.

Check warning on line 1029 in PolyPilot/Services/CopilotService.cs

View workflow job for this annotation

GitHub Actions / Build & Test

Possible null reference assignment.
if (element.TryGetProperty("args", out var args) && args.ValueKind == JsonValueKind.Array)
config.Args = args.EnumerateArray().Select(a => a.GetString() ?? "").ToList();
if (element.TryGetProperty("env", out var env) && env.ValueKind == JsonValueKind.Object)
Expand Down Expand Up @@ -1398,6 +1436,7 @@
throw new InvalidOperationException("Session is already processing a request.");

state.Info.IsProcessing = true;
Debug($"[SEND] '{sessionName}' IsProcessing=true (thread={Environment.CurrentManagedThreadId})");
state.ResponseCompletion = new TaskCompletionSource<string>();
state.CurrentResponse.Clear();
StartProcessingWatchdog(state, sessionName);
Expand Down Expand Up @@ -1464,6 +1503,8 @@
var newSession = await _client.ResumeSessionAsync(state.Info.SessionId, reconnectConfig, cancellationToken);
// Cancel old watchdog BEFORE creating new state — they share Info/TCS
CancelProcessingWatchdog(state);
Debug($"[RECONNECT] '{sessionName}' replacing state (old handler will be orphaned, " +
$"old session disposed, new session={newSession.SessionId})");
var newState = new SessionState
{
Session = newSession,
Expand Down
Loading