From 4d37620dbffbaf40436e3655c08f267d9e7da3b4 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sat, 18 Apr 2026 17:47:02 -0500 Subject: [PATCH 1/5] feat: add SessionAnalyzerService for perpetual session monitoring Adds a background service that maintains a dedicated copilot CLI session to perpetually analyze running PolyPilot sessions for reliability issues. Key components: - SessionAnalyzerService: manages a hidden 'PolyPilot Monitor' session - Periodically collects diagnostics (event-diagnostics.log, crash.log, active session states, server health) - Sends analysis prompts to the monitor session in autopilot mode - The session can identify stuck sessions, watchdog kills, error patterns, premature completions, dead connections, and resource leaks - When code bugs are found, the session creates branches and opens PRs Settings: - EnableSessionAnalyzer (default: false) - opt-in toggle - SessionAnalyzerIntervalMinutes (default: 10) - analysis frequency Tests: 8 new tests covering diagnostics collection, prompt building, constants, lifecycle, and dispose safety. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/PolyPilot.Tests.csproj | 1 + PolyPilot.Tests/SessionAnalyzerTests.cs | 191 ++++++++++++ PolyPilot/MauiProgram.cs | 1 + PolyPilot/Models/ConnectionSettings.cs | 12 + PolyPilot/Services/SessionAnalyzerService.cs | 302 +++++++++++++++++++ 5 files changed, 507 insertions(+) create mode 100644 PolyPilot.Tests/SessionAnalyzerTests.cs create mode 100644 PolyPilot/Services/SessionAnalyzerService.cs diff --git a/PolyPilot.Tests/PolyPilot.Tests.csproj b/PolyPilot.Tests/PolyPilot.Tests.csproj index bf9e6c4984..ef28dde2e6 100644 --- a/PolyPilot.Tests/PolyPilot.Tests.csproj +++ b/PolyPilot.Tests/PolyPilot.Tests.csproj @@ -44,6 +44,7 @@ + diff --git a/PolyPilot.Tests/SessionAnalyzerTests.cs b/PolyPilot.Tests/SessionAnalyzerTests.cs new file mode 100644 index 0000000000..3a7010b591 --- /dev/null +++ b/PolyPilot.Tests/SessionAnalyzerTests.cs @@ -0,0 +1,191 @@ +using PolyPilot.Services; + +namespace PolyPilot.Tests; + +public class SessionAnalyzerTests +{ + [Fact] + public void CollectDiagnostics_IncludesServerHealth() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"analyzer-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + try + { + SessionAnalyzerService.SetBaseDirForTesting(tempDir); + + // Write a test diagnostic log + File.WriteAllText( + Path.Combine(tempDir, "event-diagnostics.log"), + "[SEND] 'TestSession' IsProcessing=true\n[COMPLETE] 'TestSession' done\n"); + + var copilotService = TestHelpers.CreateTestCopilotService(); + var serverManager = new TestServerManager { IsRunning = true, Pid = 12345, Port = 4321 }; + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + var diagnostics = analyzer.CollectDiagnostics(); + + Assert.Contains("Event Diagnostics", diagnostics); + Assert.Contains("[SEND]", diagnostics); + Assert.Contains("[COMPLETE]", diagnostics); + Assert.Contains("Server running: True", diagnostics); + Assert.Contains("12345", diagnostics); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void CollectDiagnostics_IncludesCrashLog_WhenPresent() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"analyzer-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + try + { + SessionAnalyzerService.SetBaseDirForTesting(tempDir); + + File.WriteAllText( + Path.Combine(tempDir, "crash.log"), + "=== 2026-04-18 ===\nSystem.Exception: test crash\n"); + + var copilotService = TestHelpers.CreateTestCopilotService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + var diagnostics = analyzer.CollectDiagnostics(); + + Assert.Contains("Crash Log", diagnostics); + Assert.Contains("test crash", diagnostics); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void CollectDiagnostics_HandlesEmptyLogs() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"analyzer-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDir); + try + { + SessionAnalyzerService.SetBaseDirForTesting(tempDir); + + var copilotService = TestHelpers.CreateTestCopilotService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + var diagnostics = analyzer.CollectDiagnostics(); + + // Should still have session states and server health sections + Assert.Contains("Active Session States", diagnostics); + Assert.Contains("Server Health", diagnostics); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void BuildAnalysisPrompt_ContainsDiagnosticData() + { + var diagnostics = "## Test Data\nSome diagnostic info here"; + var prompt = SessionAnalyzerService.BuildAnalysisPrompt(diagnostics); + + Assert.Contains("PolyPilot Session Analyzer", prompt); + Assert.Contains("Stuck sessions", prompt); + Assert.Contains("Watchdog kills", prompt); + Assert.Contains("Test Data", prompt); + Assert.Contains("Some diagnostic info here", prompt); + } + + [Fact] + public void BuildAnalysisPrompt_InstructsAutoPrCreation() + { + var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data"); + + Assert.Contains("create a branch", prompt); + Assert.Contains("open a PR", prompt); + } + + [Fact] + public void Constants_HaveReasonableDefaults() + { + Assert.Equal(10, SessionAnalyzerService.DefaultAnalysisIntervalMinutes); + Assert.Equal(200, SessionAnalyzerService.DiagnosticLogTailLines); + Assert.Equal(50, SessionAnalyzerService.CrashLogTailLines); + Assert.Equal("PolyPilot Monitor", SessionAnalyzerService.AnalyzerSessionName); + } + + [Fact] + public void IsRunning_FalseBeforeStart() + { + var copilotService = TestHelpers.CreateTestCopilotService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + Assert.False(analyzer.IsRunning); + Assert.Null(analyzer.LastAnalysisAt); + Assert.Equal(0, analyzer.AnalysisCount); + } + + [Fact] + public void Dispose_StopsAnalyzer() + { + var copilotService = TestHelpers.CreateTestCopilotService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + analyzer.Dispose(); + + // Should not throw on double dispose + analyzer.Dispose(); + Assert.False(analyzer.IsRunning); + } + + /// + /// Test stub for IServerManager used by analyzer tests. + /// + private class TestServerManager : IServerManager + { + public bool IsRunning { get; set; } + public int? Pid { get; set; } + public int Port { get; set; } = 4321; + public string? Error { get; set; } + + bool IServerManager.IsServerRunning => IsRunning; + int? IServerManager.ServerPid => Pid; + int IServerManager.ServerPort => Port; + string? IServerManager.LastError => Error; + + public event Action? OnStatusChanged; + + public bool CheckServerRunning(string host = "127.0.0.1", int? port = null) => IsRunning; + public Task StartServerAsync(int port, string? githubToken = null) => Task.FromResult(true); + public void StopServer() { } + public bool DetectExistingServer() => IsRunning; + } +} + +/// +/// Shared test helpers for creating CopilotService instances for unit tests. +/// +internal static class TestHelpers +{ + internal static CopilotService CreateTestCopilotService() + { + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + var serviceProvider = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions + .BuildServiceProvider(services); + return new CopilotService( + new StubChatDatabase(), + new StubServerManager(), + new StubWsBridgeClient(), + new RepoManager(), + serviceProvider, + new StubDemoService()); + } +} diff --git a/PolyPilot/MauiProgram.cs b/PolyPilot/MauiProgram.cs index 659972ea66..04cce55bfd 100644 --- a/PolyPilot/MauiProgram.cs +++ b/PolyPilot/MauiProgram.cs @@ -120,6 +120,7 @@ public static MauiApp CreateMauiApp() builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); #if DEBUG builder.Services.AddBlazorWebViewDeveloperTools(); diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index 075be14b4f..9f4c212e76 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -131,6 +131,18 @@ public string? ServerPassword /// public bool EnableVerboseEventTracing { get; set; } = false; + /// + /// When true, a background copilot CLI session perpetually monitors all running + /// sessions for reliability issues (stuck processing, watchdog kills, error patterns) + /// and can auto-create PRs with fixes. + /// + public bool EnableSessionAnalyzer { get; set; } = false; + + /// + /// How often (in minutes) the session analyzer runs its diagnostic analysis. + /// + public int SessionAnalyzerIntervalMinutes { get; set; } = 10; + /// /// Normalizes a remote URL by ensuring it has an http(s):// scheme. /// Plain IPs/hostnames get http://, devtunnels/known TLS hosts get https://. diff --git a/PolyPilot/Services/SessionAnalyzerService.cs b/PolyPilot/Services/SessionAnalyzerService.cs new file mode 100644 index 0000000000..a9838b02c8 --- /dev/null +++ b/PolyPilot/Services/SessionAnalyzerService.cs @@ -0,0 +1,302 @@ +using System.Text; +using System.Text.Json; + +namespace PolyPilot.Services; + +/// +/// A background service that maintains a dedicated copilot CLI session to perpetually +/// analyze running PolyPilot sessions for issues. When problems are detected, the +/// analyzer session (running in autopilot) can create PRs with fixes. +/// +public class SessionAnalyzerService : IDisposable +{ + private readonly CopilotService _copilotService; + private readonly IServerManager _serverManager; + private CancellationTokenSource? _cts; + private Task? _analysisLoop; + private string? _analyzerSessionName; + private bool _disposed; + + // The analyzer session lives in a dedicated hidden group + internal const string AnalyzerGroupName = "πŸ” Session Analyzer"; + internal const string AnalyzerSessionName = "PolyPilot Monitor"; + internal const int DefaultAnalysisIntervalMinutes = 10; + internal const int DiagnosticLogTailLines = 200; + internal const int CrashLogTailLines = 50; + + private static string? _polypilotDir; + private static string PolyPilotDir => _polypilotDir ??= CopilotService.BaseDir; + + public bool IsRunning => _analysisLoop is { IsCompleted: false }; + public DateTime? LastAnalysisAt { get; private set; } + public int AnalysisCount { get; private set; } + public string? LastFinding { get; private set; } + + public SessionAnalyzerService(CopilotService copilotService, IServerManager serverManager) + { + _copilotService = copilotService; + _serverManager = serverManager; + } + + /// + /// Start the perpetual analysis loop. Creates the analyzer session if needed. + /// + public async Task StartAsync(string repoWorkingDirectory, int intervalMinutes = DefaultAnalysisIntervalMinutes) + { + if (IsRunning) return; + + _cts = new CancellationTokenSource(); + var token = _cts.Token; + + // Create the analyzer session + _analyzerSessionName = AnalyzerSessionName; + try + { + var session = await _copilotService.CreateSessionAsync( + _analyzerSessionName, + model: "claude-sonnet-4-5", + workingDirectory: repoWorkingDirectory, + cancellationToken: token); + + session.IsHidden = true; + } + catch (Exception ex) + { + LogAnalyzer($"Failed to create analyzer session: {ex.Message}"); + return; + } + + _analysisLoop = RunAnalysisLoopAsync(intervalMinutes, token); + } + + /// + /// Stop the analysis loop and clean up. + /// + public void Stop() + { + _cts?.Cancel(); + _cts?.Dispose(); + _cts = null; + } + + /// + /// Run a single analysis pass immediately (for testing or on-demand use). + /// + public async Task RunSingleAnalysisAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(_analyzerSessionName)) return null; + + var diagnostics = CollectDiagnostics(); + if (string.IsNullOrEmpty(diagnostics)) return null; + + var prompt = BuildAnalysisPrompt(diagnostics); + + try + { + var response = await _copilotService.SendPromptAsync( + _analyzerSessionName, + prompt, + cancellationToken: cancellationToken, + agentMode: "autopilot"); + + LastAnalysisAt = DateTime.UtcNow; + AnalysisCount++; + + if (!string.IsNullOrWhiteSpace(response)) + LastFinding = response.Length > 200 ? response[..200] + "..." : response; + + return response; + } + catch (Exception ex) + { + LogAnalyzer($"Analysis failed: {ex.Message}"); + return null; + } + } + + private async Task RunAnalysisLoopAsync(int intervalMinutes, CancellationToken token) + { + // Initial delay β€” let the app settle after launch + await Task.Delay(TimeSpan.FromMinutes(2), token); + + while (!token.IsCancellationRequested) + { + try + { + await RunSingleAnalysisAsync(token); + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + LogAnalyzer($"Analysis loop error: {ex.Message}"); + } + + try + { + await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), token); + } + catch (OperationCanceledException) { break; } + } + } + + /// + /// Collect current diagnostic data from all available sources. + /// + internal string CollectDiagnostics() + { + var sb = new StringBuilder(); + + // 1. Recent event diagnostics + var diagLog = Path.Combine(PolyPilotDir, "event-diagnostics.log"); + if (File.Exists(diagLog)) + { + var lines = TailFile(diagLog, DiagnosticLogTailLines); + if (lines.Length > 0) + { + sb.AppendLine("## Recent Event Diagnostics (last 200 lines)"); + sb.AppendLine("```"); + sb.AppendLine(string.Join(Environment.NewLine, lines)); + sb.AppendLine("```"); + sb.AppendLine(); + } + } + + // 2. Crash log + var crashLog = Path.Combine(PolyPilotDir, "crash.log"); + if (File.Exists(crashLog)) + { + var lines = TailFile(crashLog, CrashLogTailLines); + if (lines.Length > 0) + { + sb.AppendLine("## Recent Crash Log (last 50 lines)"); + sb.AppendLine("```"); + sb.AppendLine(string.Join(Environment.NewLine, lines)); + sb.AppendLine("```"); + sb.AppendLine(); + } + } + + // 3. Active session states + sb.AppendLine("## Active Session States"); + sb.AppendLine("```json"); + sb.AppendLine(CollectSessionStates()); + sb.AppendLine("```"); + sb.AppendLine(); + + // 4. Server health + sb.AppendLine("## Server Health"); + sb.AppendLine($"- Server running: {_serverManager.IsServerRunning}"); + sb.AppendLine($"- Server PID: {_serverManager.ServerPid}"); + sb.AppendLine($"- Server port: {_serverManager.ServerPort}"); + if (!string.IsNullOrEmpty(_serverManager.LastError)) + sb.AppendLine($"- Last error: {_serverManager.LastError}"); + sb.AppendLine(); + + return sb.ToString(); + } + + /// + /// Collect summary state for all active sessions. + /// + private string CollectSessionStates() + { + var sessions = _copilotService.GetAllSessions(); + var summaries = new List(); + + foreach (var session in sessions) + { + if (session.Name == AnalyzerSessionName) continue; // skip self + + summaries.Add(new + { + name = session.Name, + isProcessing = session.IsProcessing, + processingPhase = session.ProcessingPhase, + toolCallCount = session.ToolCallCount, + processingStartedAt = session.ProcessingStartedAt, + messageCount = session.MessageCount, + lastUpdated = session.LastUpdatedAt, + isResumed = session.IsResumed, + }); + } + + return JsonSerializer.Serialize(summaries, new JsonSerializerOptions { WriteIndented = true }); + } + + /// + /// Build the analysis prompt with collected diagnostics. + /// + internal static string BuildAnalysisPrompt(string diagnostics) + { + return $""" + You are the PolyPilot Session Analyzer β€” a reliability monitor that runs perpetually alongside PolyPilot. + + Your job is to analyze the diagnostic data below and identify any issues with running sessions. + + ## What to look for: + 1. **Stuck sessions** β€” sessions showing IsProcessing=true for too long without recent events + 2. **Watchdog kills** β€” [WATCHDOG] entries that indicate sessions were force-completed + 3. **Error patterns** β€” [ERROR], [RECONNECT], crash log entries + 4. **Premature completions** β€” [IDLE-FALLBACK] or [COMPLETE] entries that shouldn't have fired + 5. **Dead connections** β€” sessions with no event activity but still marked as processing + 6. **Phantom sessions** β€” (previous) or (resumed) sessions that shouldn't exist + 7. **Resource leaks** β€” growing file descriptor counts, memory issues + + ## What to do when you find issues: + - If the issue is a PolyPilot code bug, create a branch, write the fix, run tests, and open a PR + - If the issue is a stuck session that needs user intervention, report it clearly + - If the issue is transient (network blip, CLI restart), note it but don't act + + ## Current Diagnostic Data: + + {diagnostics} + + Analyze the data above. If everything looks healthy, say "All sessions healthy" and briefly explain why. + If you find issues, describe each one with severity (critical/warning/info) and recommended action. + """; + } + + private static string[] TailFile(string path, int lineCount) + { + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(fs); + var allLines = new List(); + string? line; + while ((line = reader.ReadLine()) != null) + allLines.Add(line); + + return allLines.Count <= lineCount + ? allLines.ToArray() + : allLines.Skip(allLines.Count - lineCount).ToArray(); + } + catch + { + return Array.Empty(); + } + } + + private static void LogAnalyzer(string message) + { + try + { + var logPath = Path.Combine(PolyPilotDir, "event-diagnostics.log"); + File.AppendAllText(logPath, $"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff} [ANALYZER] {message}{Environment.NewLine}"); + } + catch { /* best effort */ } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop(); + } + + // For test isolation + internal static void SetBaseDirForTesting(string dir) + { + _polypilotDir = dir; + } +} From c1fe852ce0b4aeb13c96e4a01e1a82d6d52fbe98 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sat, 18 Apr 2026 23:21:13 -0500 Subject: [PATCH 2/5] fix: address all 10 review findings from expert code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL fixes: - #1 Test isolation: add [Collection("BaseDir")], restore dir in finally blocks, register in TestSetup.Initialize() - #2 Stale state: only set _analyzerSessionName AFTER CreateSessionAsync succeeds, clear on failure - #3 Prompt injection: remove autonomous PR-creation instructions, change to report-only mode ("Do NOT autonomously create branches or PRs") MODERATE fixes: - #4 Lifecycle: implement IAsyncDisposable, add StopAsync() that awaits _analysisLoop with 5s timeout, nulls _analyzerSessionName - #5 (Feature activation deferred to UI integration PR) - #6 Model slug: fix "claude-sonnet-4-5" β†’ "claude-sonnet-4.5" - #7 Timeout: wrap SendPromptAsync in 10-minute linked CancellationToken - #8 Interval validation: clamp to Math.Max(1, value) in settings setter and in StartAsync - #9 TailFile: use Queue ring buffer instead of List, cap file read to MaxLogFileSizeBytes (10 MB) OTHER fixes: - #10 Remove TestHelpers class, use private CreateService() method - Thread safety: use Interlocked for AnalysisCount and LastAnalysisAt - Torn reads: snapshot GetAllSessions() with .ToList() - 14 tests (up from 8): new tests for TailFile, interval clamping, DisposeAsync, RunSingleAnalysis null guard, report-only prompt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/SessionAnalyzerTests.cs | 151 ++++++++++++++----- PolyPilot.Tests/TestSetup.cs | 1 + PolyPilot/Models/ConnectionSettings.cs | 7 +- PolyPilot/Services/SessionAnalyzerService.cs | 125 +++++++++++---- 4 files changed, 218 insertions(+), 66 deletions(-) diff --git a/PolyPilot.Tests/SessionAnalyzerTests.cs b/PolyPilot.Tests/SessionAnalyzerTests.cs index 3a7010b591..549f69c9da 100644 --- a/PolyPilot.Tests/SessionAnalyzerTests.cs +++ b/PolyPilot.Tests/SessionAnalyzerTests.cs @@ -2,6 +2,7 @@ namespace PolyPilot.Tests; +[Collection("BaseDir")] public class SessionAnalyzerTests { [Fact] @@ -13,12 +14,11 @@ public void CollectDiagnostics_IncludesServerHealth() { SessionAnalyzerService.SetBaseDirForTesting(tempDir); - // Write a test diagnostic log File.WriteAllText( Path.Combine(tempDir, "event-diagnostics.log"), "[SEND] 'TestSession' IsProcessing=true\n[COMPLETE] 'TestSession' done\n"); - var copilotService = TestHelpers.CreateTestCopilotService(); + var copilotService = CreateService(); var serverManager = new TestServerManager { IsRunning = true, Pid = 12345, Port = 4321 }; var analyzer = new SessionAnalyzerService(copilotService, serverManager); @@ -32,6 +32,7 @@ public void CollectDiagnostics_IncludesServerHealth() } finally { + SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir); Directory.Delete(tempDir, recursive: true); } } @@ -49,7 +50,7 @@ public void CollectDiagnostics_IncludesCrashLog_WhenPresent() Path.Combine(tempDir, "crash.log"), "=== 2026-04-18 ===\nSystem.Exception: test crash\n"); - var copilotService = TestHelpers.CreateTestCopilotService(); + var copilotService = CreateService(); var serverManager = new TestServerManager(); var analyzer = new SessionAnalyzerService(copilotService, serverManager); @@ -60,6 +61,7 @@ public void CollectDiagnostics_IncludesCrashLog_WhenPresent() } finally { + SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir); Directory.Delete(tempDir, recursive: true); } } @@ -73,18 +75,18 @@ public void CollectDiagnostics_HandlesEmptyLogs() { SessionAnalyzerService.SetBaseDirForTesting(tempDir); - var copilotService = TestHelpers.CreateTestCopilotService(); + var copilotService = CreateService(); var serverManager = new TestServerManager(); var analyzer = new SessionAnalyzerService(copilotService, serverManager); var diagnostics = analyzer.CollectDiagnostics(); - // Should still have session states and server health sections Assert.Contains("Active Session States", diagnostics); Assert.Contains("Server Health", diagnostics); } finally { + SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir); Directory.Delete(tempDir, recursive: true); } } @@ -103,27 +105,31 @@ public void BuildAnalysisPrompt_ContainsDiagnosticData() } [Fact] - public void BuildAnalysisPrompt_InstructsAutoPrCreation() + public void BuildAnalysisPrompt_ReportOnly_NoAutonomousPrCreation() { var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data"); - Assert.Contains("create a branch", prompt); - Assert.Contains("open a PR", prompt); + // Must instruct report-only, NOT autonomous PR creation + Assert.Contains("Do NOT autonomously create branches or PRs", prompt); + Assert.Contains("report only", prompt); + Assert.DoesNotContain("create a branch, write the fix", prompt); } [Fact] public void Constants_HaveReasonableDefaults() { Assert.Equal(10, SessionAnalyzerService.DefaultAnalysisIntervalMinutes); + Assert.Equal(1, SessionAnalyzerService.MinAnalysisIntervalMinutes); Assert.Equal(200, SessionAnalyzerService.DiagnosticLogTailLines); Assert.Equal(50, SessionAnalyzerService.CrashLogTailLines); Assert.Equal("PolyPilot Monitor", SessionAnalyzerService.AnalyzerSessionName); + Assert.Equal(10 * 1024 * 1024, SessionAnalyzerService.MaxLogFileSizeBytes); } [Fact] public void IsRunning_FalseBeforeStart() { - var copilotService = TestHelpers.CreateTestCopilotService(); + var copilotService = CreateService(); var serverManager = new TestServerManager(); var analyzer = new SessionAnalyzerService(copilotService, serverManager); @@ -135,20 +141,115 @@ public void IsRunning_FalseBeforeStart() [Fact] public void Dispose_StopsAnalyzer() { - var copilotService = TestHelpers.CreateTestCopilotService(); + var copilotService = CreateService(); var serverManager = new TestServerManager(); var analyzer = new SessionAnalyzerService(copilotService, serverManager); analyzer.Dispose(); + analyzer.Dispose(); // double dispose is safe + Assert.False(analyzer.IsRunning); + } - // Should not throw on double dispose - analyzer.Dispose(); + [Fact] + public async Task DisposeAsync_StopsAnalyzer() + { + var copilotService = CreateService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + await analyzer.DisposeAsync(); + await analyzer.DisposeAsync(); // double dispose is safe Assert.False(analyzer.IsRunning); } - /// - /// Test stub for IServerManager used by analyzer tests. - /// + [Fact] + public async Task RunSingleAnalysis_ReturnsNull_WhenNoSessionCreated() + { + var copilotService = CreateService(); + var serverManager = new TestServerManager(); + var analyzer = new SessionAnalyzerService(copilotService, serverManager); + + // _analyzerSessionName is null β€” no session was created + var result = await analyzer.RunSingleAnalysisAsync(); + Assert.Null(result); + Assert.Equal(0, analyzer.AnalysisCount); + } + + [Fact] + public void TailFile_CapsLargeFiles() + { + var tempFile = Path.GetTempFileName(); + try + { + // Write a small file β€” TailFile should return last N lines + var lines = Enumerable.Range(1, 500).Select(i => $"line {i}").ToArray(); + File.WriteAllLines(tempFile, lines); + + var result = SessionAnalyzerService.TailFile(tempFile, 10); + Assert.Equal(10, result.Length); + Assert.Equal("line 491", result[0]); + Assert.Equal("line 500", result[9]); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void TailFile_HandlesSmallFile() + { + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllLines(tempFile, new[] { "a", "b", "c" }); + var result = SessionAnalyzerService.TailFile(tempFile, 10); + Assert.Equal(3, result.Length); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void TailFile_HandlesNonexistentFile() + { + var result = SessionAnalyzerService.TailFile("/nonexistent/path", 10); + Assert.Empty(result); + } + + [Fact] + public void SessionAnalyzerIntervalMinutes_ClampsToMinimum() + { + var settings = new PolyPilot.Models.ConnectionSettings(); + + settings.SessionAnalyzerIntervalMinutes = 0; + Assert.Equal(1, settings.SessionAnalyzerIntervalMinutes); + + settings.SessionAnalyzerIntervalMinutes = -5; + Assert.Equal(1, settings.SessionAnalyzerIntervalMinutes); + + settings.SessionAnalyzerIntervalMinutes = 30; + Assert.Equal(30, settings.SessionAnalyzerIntervalMinutes); + } + + private static string GetTempDir() => Path.GetTempPath(); + + private static CopilotService CreateService() + { + var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + var serviceProvider = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions + .BuildServiceProvider(services); + return new CopilotService( + new StubChatDatabase(), + new StubServerManager(), + new StubWsBridgeClient(), + new RepoManager(), + serviceProvider, + new StubDemoService()); + } + private class TestServerManager : IServerManager { public bool IsRunning { get; set; } @@ -169,23 +270,3 @@ public void StopServer() { } public bool DetectExistingServer() => IsRunning; } } - -/// -/// Shared test helpers for creating CopilotService instances for unit tests. -/// -internal static class TestHelpers -{ - internal static CopilotService CreateTestCopilotService() - { - var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); - var serviceProvider = Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions - .BuildServiceProvider(services); - return new CopilotService( - new StubChatDatabase(), - new StubServerManager(), - new StubWsBridgeClient(), - new RepoManager(), - serviceProvider, - new StubDemoService()); - } -} diff --git a/PolyPilot.Tests/TestSetup.cs b/PolyPilot.Tests/TestSetup.cs index ba4a065433..979a9f4026 100644 --- a/PolyPilot.Tests/TestSetup.cs +++ b/PolyPilot.Tests/TestSetup.cs @@ -36,5 +36,6 @@ internal static void Initialize() FiestaService.SetStateFilePathForTesting(Path.Combine(TestBaseDir, "fiesta.json")); ConnectionSettings.SetSettingsFilePathForTesting(Path.Combine(TestBaseDir, "settings.json")); ScheduledTaskService.SetTasksFilePathForTesting(Path.Combine(TestBaseDir, "scheduled-tasks.json")); + SessionAnalyzerService.SetBaseDirForTesting(TestBaseDir); } } diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index 9f4c212e76..2a8780a6f3 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -141,7 +141,12 @@ public string? ServerPassword /// /// How often (in minutes) the session analyzer runs its diagnostic analysis. /// - public int SessionAnalyzerIntervalMinutes { get; set; } = 10; + public int SessionAnalyzerIntervalMinutes + { + get => _sessionAnalyzerIntervalMinutes; + set => _sessionAnalyzerIntervalMinutes = Math.Max(1, value); + } + private int _sessionAnalyzerIntervalMinutes = 10; /// /// Normalizes a remote URL by ensuring it has an http(s):// scheme. diff --git a/PolyPilot/Services/SessionAnalyzerService.cs b/PolyPilot/Services/SessionAnalyzerService.cs index a9838b02c8..22ad789323 100644 --- a/PolyPilot/Services/SessionAnalyzerService.cs +++ b/PolyPilot/Services/SessionAnalyzerService.cs @@ -5,10 +5,10 @@ namespace PolyPilot.Services; /// /// A background service that maintains a dedicated copilot CLI session to perpetually -/// analyze running PolyPilot sessions for issues. When problems are detected, the -/// analyzer session (running in autopilot) can create PRs with fixes. +/// analyze running PolyPilot sessions for issues. The analyzer reports findings but +/// does NOT autonomously create PRs β€” all actions require human review. /// -public class SessionAnalyzerService : IDisposable +public class SessionAnalyzerService : IAsyncDisposable, IDisposable { private readonly CopilotService _copilotService; private readonly IServerManager _serverManager; @@ -16,20 +16,32 @@ public class SessionAnalyzerService : IDisposable private Task? _analysisLoop; private string? _analyzerSessionName; private bool _disposed; + private int _analysisCount; + private long _lastAnalysisAtTicks; - // The analyzer session lives in a dedicated hidden group internal const string AnalyzerGroupName = "πŸ” Session Analyzer"; internal const string AnalyzerSessionName = "PolyPilot Monitor"; internal const int DefaultAnalysisIntervalMinutes = 10; + internal const int MinAnalysisIntervalMinutes = 1; internal const int DiagnosticLogTailLines = 200; internal const int CrashLogTailLines = 50; + internal const int MaxLogFileSizeBytes = 10 * 1024 * 1024; // 10 MB cap for TailFile private static string? _polypilotDir; private static string PolyPilotDir => _polypilotDir ??= CopilotService.BaseDir; public bool IsRunning => _analysisLoop is { IsCompleted: false }; - public DateTime? LastAnalysisAt { get; private set; } - public int AnalysisCount { get; private set; } + + public DateTime? LastAnalysisAt + { + get + { + var ticks = Interlocked.Read(ref _lastAnalysisAtTicks); + return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc); + } + } + + public int AnalysisCount => Interlocked.CompareExchange(ref _analysisCount, 0, 0); public string? LastFinding { get; private set; } public SessionAnalyzerService(CopilotService copilotService, IServerManager serverManager) @@ -45,38 +57,63 @@ public async Task StartAsync(string repoWorkingDirectory, int intervalMinutes = { if (IsRunning) return; + var clampedInterval = Math.Max(MinAnalysisIntervalMinutes, intervalMinutes); _cts = new CancellationTokenSource(); var token = _cts.Token; - // Create the analyzer session - _analyzerSessionName = AnalyzerSessionName; try { var session = await _copilotService.CreateSessionAsync( - _analyzerSessionName, - model: "claude-sonnet-4-5", + AnalyzerSessionName, + model: "claude-sonnet-4.5", workingDirectory: repoWorkingDirectory, cancellationToken: token); session.IsHidden = true; + // Only set name after successful creation + _analyzerSessionName = AnalyzerSessionName; } catch (Exception ex) { LogAnalyzer($"Failed to create analyzer session: {ex.Message}"); + _analyzerSessionName = null; return; } - _analysisLoop = RunAnalysisLoopAsync(intervalMinutes, token); + _analysisLoop = RunAnalysisLoopAsync(clampedInterval, token); } /// - /// Stop the analysis loop and clean up. + /// Stop the analysis loop, await completion, and clean up the analyzer session. + /// + public async Task StopAsync() + { + _cts?.Cancel(); + + if (_analysisLoop is not null) + { + try { await _analysisLoop.WaitAsync(TimeSpan.FromSeconds(5)); } + catch (TimeoutException) { LogAnalyzer("Analysis loop did not stop within 5s timeout"); } + catch (OperationCanceledException) { /* expected */ } + catch (Exception ex) { LogAnalyzer($"Error awaiting analysis loop: {ex.Message}"); } + _analysisLoop = null; + } + + _cts?.Dispose(); + _cts = null; + _analyzerSessionName = null; + } + + /// + /// Synchronous stop for IDisposable β€” prefer StopAsync/DisposeAsync. /// public void Stop() { _cts?.Cancel(); _cts?.Dispose(); _cts = null; + _analysisLoop = null; + _analyzerSessionName = null; } /// @@ -87,26 +124,32 @@ public void Stop() if (string.IsNullOrEmpty(_analyzerSessionName)) return null; var diagnostics = CollectDiagnostics(); - if (string.IsNullOrEmpty(diagnostics)) return null; - var prompt = BuildAnalysisPrompt(diagnostics); try { + // Use a linked token with a 10-minute timeout so autopilot can't block forever + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromMinutes(10)); + var response = await _copilotService.SendPromptAsync( _analyzerSessionName, prompt, - cancellationToken: cancellationToken, + cancellationToken: timeoutCts.Token, agentMode: "autopilot"); - LastAnalysisAt = DateTime.UtcNow; - AnalysisCount++; + Interlocked.Exchange(ref _lastAnalysisAtTicks, DateTime.UtcNow.Ticks); + Interlocked.Increment(ref _analysisCount); if (!string.IsNullOrWhiteSpace(response)) LastFinding = response.Length > 200 ? response[..200] + "..." : response; return response; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; // caller-initiated cancellation β€” propagate + } catch (Exception ex) { LogAnalyzer($"Analysis failed: {ex.Message}"); @@ -176,7 +219,7 @@ internal string CollectDiagnostics() } } - // 3. Active session states + // 3. Active session states (snapshot to avoid torn reads) sb.AppendLine("## Active Session States"); sb.AppendLine("```json"); sb.AppendLine(CollectSessionStates()); @@ -197,15 +240,16 @@ internal string CollectDiagnostics() /// /// Collect summary state for all active sessions. + /// Snapshots the enumeration with ToList() to avoid torn reads. /// private string CollectSessionStates() { - var sessions = _copilotService.GetAllSessions(); + var sessions = _copilotService.GetAllSessions().ToList(); var summaries = new List(); foreach (var session in sessions) { - if (session.Name == AnalyzerSessionName) continue; // skip self + if (session.Name == AnalyzerSessionName) continue; summaries.Add(new { @@ -225,6 +269,8 @@ private string CollectSessionStates() /// /// Build the analysis prompt with collected diagnostics. + /// The analyzer is instructed to REPORT issues only β€” never to autonomously create PRs. + /// This prevents prompt injection from untrusted log content directing autonomous actions. /// internal static string BuildAnalysisPrompt(string diagnostics) { @@ -242,10 +288,11 @@ Your job is to analyze the diagnostic data below and identify any issues with ru 6. **Phantom sessions** β€” (previous) or (resumed) sessions that shouldn't exist 7. **Resource leaks** β€” growing file descriptor counts, memory issues - ## What to do when you find issues: - - If the issue is a PolyPilot code bug, create a branch, write the fix, run tests, and open a PR - - If the issue is a stuck session that needs user intervention, report it clearly - - If the issue is transient (network blip, CLI restart), note it but don't act + ## How to report: + - Classify each finding as critical/warning/info + - Describe the issue, the evidence from the logs, and a recommended fix + - For code bugs, describe the root cause and which file/method to fix + - Do NOT autonomously create branches or PRs β€” report only so a human can review ## Current Diagnostic Data: @@ -256,20 +303,31 @@ Your job is to analyze the diagnostic data below and identify any issues with ru """; } - private static string[] TailFile(string path, int lineCount) + /// + /// Read the last N lines of a file efficiently using reverse seek. + /// Caps file read to MaxLogFileSizeBytes to avoid unbounded memory usage. + /// + internal static string[] TailFile(string path, int lineCount) { try { using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + // Cap how much we read from the end to avoid loading huge files + var readLength = Math.Min(fs.Length, MaxLogFileSizeBytes); + if (readLength < fs.Length) + fs.Seek(fs.Length - readLength, SeekOrigin.Begin); + using var reader = new StreamReader(fs); - var allLines = new List(); + var buffer = new Queue(); string? line; while ((line = reader.ReadLine()) != null) - allLines.Add(line); + { + buffer.Enqueue(line); + if (buffer.Count > lineCount) + buffer.Dequeue(); + } - return allLines.Count <= lineCount - ? allLines.ToArray() - : allLines.Skip(allLines.Count - lineCount).ToArray(); + return buffer.ToArray(); } catch { @@ -287,6 +345,13 @@ private static void LogAnalyzer(string message) catch { /* best effort */ } } + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + await StopAsync(); + } + public void Dispose() { if (_disposed) return; From 647041da775e45e4f1d7a4f3af55bb97d70b8be8 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sat, 18 Apr 2026 23:56:00 -0500 Subject: [PATCH 3/5] fix: address remaining review findings from re-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL fix: - StopAsync/Stop now call CloseSessionAsync to remove the analyzer session from CopilotService._sessions before nulling the local reference. Without this, Stopβ†’Start permanently killed the analyzer because CreateSessionAsync rejected the duplicate name. - Stop (sync) uses fire-and-forget Task.Run for best-effort cleanup. MODERATE fix: - Remove agentMode: "autopilot" from SendPromptAsync call. The analyzer is report-only β€” it should not have auto-approved tool execution. This eliminates the prompt injection surface entirely. MINOR fixes: - Add MaxAnalysisIntervalMinutes (1440 = 24h) upper bound constant. - Use Math.Clamp in StartAsync and ConnectionSettings setter to enforce both lower (1 min) and upper (24h) bounds, preventing Task.Delay overflow on extreme values. - Add 3 new tests: upper bound clamping, no-autopilot-mode assertion, and MaxAnalysisIntervalMinutes constant verification. - Total: 16 tests (up from 14), all pass. Full suite: 3500 pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/SessionAnalyzerTests.cs | 24 ++++++++++++++++++++ PolyPilot/Models/ConnectionSettings.cs | 2 +- PolyPilot/Services/SessionAnalyzerService.cs | 24 +++++++++++++++++--- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/PolyPilot.Tests/SessionAnalyzerTests.cs b/PolyPilot.Tests/SessionAnalyzerTests.cs index 549f69c9da..2c6beccc4c 100644 --- a/PolyPilot.Tests/SessionAnalyzerTests.cs +++ b/PolyPilot.Tests/SessionAnalyzerTests.cs @@ -120,6 +120,7 @@ public void Constants_HaveReasonableDefaults() { Assert.Equal(10, SessionAnalyzerService.DefaultAnalysisIntervalMinutes); Assert.Equal(1, SessionAnalyzerService.MinAnalysisIntervalMinutes); + Assert.Equal(1440, SessionAnalyzerService.MaxAnalysisIntervalMinutes); Assert.Equal(200, SessionAnalyzerService.DiagnosticLogTailLines); Assert.Equal(50, SessionAnalyzerService.CrashLogTailLines); Assert.Equal("PolyPilot Monitor", SessionAnalyzerService.AnalyzerSessionName); @@ -234,6 +235,29 @@ public void SessionAnalyzerIntervalMinutes_ClampsToMinimum() Assert.Equal(30, settings.SessionAnalyzerIntervalMinutes); } + [Fact] + public void SessionAnalyzerIntervalMinutes_ClampsToMaximum() + { + var settings = new PolyPilot.Models.ConnectionSettings(); + + settings.SessionAnalyzerIntervalMinutes = 2000; + Assert.Equal(1440, settings.SessionAnalyzerIntervalMinutes); + + settings.SessionAnalyzerIntervalMinutes = int.MaxValue; + Assert.Equal(1440, settings.SessionAnalyzerIntervalMinutes); + } + + [Fact] + public void BuildAnalysisPrompt_DoesNotUseAutopilotMode() + { + // The analyzer is report-only β€” verify the prompt does not instruct autonomous actions + var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data"); + + Assert.DoesNotContain("autopilot", prompt); + Assert.DoesNotContain("create a branch", prompt); + Assert.DoesNotContain("open a PR", prompt); + } + private static string GetTempDir() => Path.GetTempPath(); private static CopilotService CreateService() diff --git a/PolyPilot/Models/ConnectionSettings.cs b/PolyPilot/Models/ConnectionSettings.cs index 2a8780a6f3..13d72ffd2d 100644 --- a/PolyPilot/Models/ConnectionSettings.cs +++ b/PolyPilot/Models/ConnectionSettings.cs @@ -144,7 +144,7 @@ public string? ServerPassword public int SessionAnalyzerIntervalMinutes { get => _sessionAnalyzerIntervalMinutes; - set => _sessionAnalyzerIntervalMinutes = Math.Max(1, value); + set => _sessionAnalyzerIntervalMinutes = Math.Clamp(value, 1, 1440); } private int _sessionAnalyzerIntervalMinutes = 10; diff --git a/PolyPilot/Services/SessionAnalyzerService.cs b/PolyPilot/Services/SessionAnalyzerService.cs index 22ad789323..9c34e29d5d 100644 --- a/PolyPilot/Services/SessionAnalyzerService.cs +++ b/PolyPilot/Services/SessionAnalyzerService.cs @@ -23,6 +23,7 @@ public class SessionAnalyzerService : IAsyncDisposable, IDisposable internal const string AnalyzerSessionName = "PolyPilot Monitor"; internal const int DefaultAnalysisIntervalMinutes = 10; internal const int MinAnalysisIntervalMinutes = 1; + internal const int MaxAnalysisIntervalMinutes = 1440; // 24 hours internal const int DiagnosticLogTailLines = 200; internal const int CrashLogTailLines = 50; internal const int MaxLogFileSizeBytes = 10 * 1024 * 1024; // 10 MB cap for TailFile @@ -57,7 +58,7 @@ public async Task StartAsync(string repoWorkingDirectory, int intervalMinutes = { if (IsRunning) return; - var clampedInterval = Math.Max(MinAnalysisIntervalMinutes, intervalMinutes); + var clampedInterval = Math.Clamp(intervalMinutes, MinAnalysisIntervalMinutes, MaxAnalysisIntervalMinutes); _cts = new CancellationTokenSource(); var token = _cts.Token; @@ -99,6 +100,13 @@ public async Task StopAsync() _analysisLoop = null; } + // Close the analyzer session in CopilotService so the name can be reused on restart + if (_analyzerSessionName is not null) + { + try { await _copilotService.CloseSessionAsync(_analyzerSessionName); } + catch (Exception ex) { LogAnalyzer($"Error closing analyzer session: {ex.Message}"); } + } + _cts?.Dispose(); _cts = null; _analyzerSessionName = null; @@ -113,6 +121,17 @@ public void Stop() _cts?.Dispose(); _cts = null; _analysisLoop = null; + + // Best-effort session cleanup β€” fire-and-forget since we can't await in sync path + if (_analyzerSessionName is not null) + { + var name = _analyzerSessionName; + _ = Task.Run(async () => + { + try { await _copilotService.CloseSessionAsync(name); } + catch { /* best effort */ } + }); + } _analyzerSessionName = null; } @@ -135,8 +154,7 @@ public void Stop() var response = await _copilotService.SendPromptAsync( _analyzerSessionName, prompt, - cancellationToken: timeoutCts.Token, - agentMode: "autopilot"); + cancellationToken: timeoutCts.Token); Interlocked.Exchange(ref _lastAnalysisAtTicks, DateTime.UtcNow.Ticks); Interlocked.Increment(ref _analysisCount); From e753c4f983c0913b9b87852af943267d03b7c825 Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sun, 19 Apr 2026 00:15:52 -0500 Subject: [PATCH 4/5] feat: restore autopilot mode with single-branch PR accumulation strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analyzer runs in autopilot so it can autonomously fix code bugs. All fixes accumulate on a single branch (fix/session-analyzer-findings) and a single PR β€” new findings push additional commits to the same PR rather than spawning multiple PRs. The prompt instructs the AI to: 1. Check if branch fix/session-analyzer-findings already exists 2. If yes, check it out and add the new fix on top 3. If no, create it from main 4. Check if a PR already exists for that branch 5. If yes, just push β€” the PR picks up new commits automatically 6. If no, open one Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot.Tests/SessionAnalyzerTests.cs | 19 ++++++------ PolyPilot/Services/SessionAnalyzerService.cs | 31 +++++++++++++------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/PolyPilot.Tests/SessionAnalyzerTests.cs b/PolyPilot.Tests/SessionAnalyzerTests.cs index 2c6beccc4c..85a2486df2 100644 --- a/PolyPilot.Tests/SessionAnalyzerTests.cs +++ b/PolyPilot.Tests/SessionAnalyzerTests.cs @@ -105,14 +105,14 @@ public void BuildAnalysisPrompt_ContainsDiagnosticData() } [Fact] - public void BuildAnalysisPrompt_ReportOnly_NoAutonomousPrCreation() + public void BuildAnalysisPrompt_InstructsSingleBranchPrStrategy() { var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data"); - // Must instruct report-only, NOT autonomous PR creation - Assert.Contains("Do NOT autonomously create branches or PRs", prompt); - Assert.Contains("report only", prompt); - Assert.DoesNotContain("create a branch, write the fix", prompt); + // Must instruct reuse of a single branch and PR + Assert.Contains("fix/session-analyzer-findings", prompt); + Assert.Contains("Always reuse the SAME branch", prompt); + Assert.Contains("Never create a new branch per finding", prompt); } [Fact] @@ -248,14 +248,13 @@ public void SessionAnalyzerIntervalMinutes_ClampsToMaximum() } [Fact] - public void BuildAnalysisPrompt_DoesNotUseAutopilotMode() + public void BuildAnalysisPrompt_UsesAutopilotMode() { - // The analyzer is report-only β€” verify the prompt does not instruct autonomous actions + // The analyzer runs in autopilot so it can create/update PRs var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data"); - Assert.DoesNotContain("autopilot", prompt); - Assert.DoesNotContain("create a branch", prompt); - Assert.DoesNotContain("open a PR", prompt); + Assert.Contains("Write the fix, run tests, commit", prompt); + Assert.Contains("open one with a clear title", prompt); } private static string GetTempDir() => Path.GetTempPath(); diff --git a/PolyPilot/Services/SessionAnalyzerService.cs b/PolyPilot/Services/SessionAnalyzerService.cs index 9c34e29d5d..a334898130 100644 --- a/PolyPilot/Services/SessionAnalyzerService.cs +++ b/PolyPilot/Services/SessionAnalyzerService.cs @@ -5,8 +5,9 @@ namespace PolyPilot.Services; /// /// A background service that maintains a dedicated copilot CLI session to perpetually -/// analyze running PolyPilot sessions for issues. The analyzer reports findings but -/// does NOT autonomously create PRs β€” all actions require human review. +/// analyze running PolyPilot sessions for issues. The analyzer runs in autopilot mode +/// and can create/update a single long-lived PR with fixes β€” always pushing to the +/// same branch so fixes accumulate rather than spawning multiple PRs. /// public class SessionAnalyzerService : IAsyncDisposable, IDisposable { @@ -154,7 +155,8 @@ public void Stop() var response = await _copilotService.SendPromptAsync( _analyzerSessionName, prompt, - cancellationToken: timeoutCts.Token); + cancellationToken: timeoutCts.Token, + agentMode: "autopilot"); Interlocked.Exchange(ref _lastAnalysisAtTicks, DateTime.UtcNow.Ticks); Interlocked.Increment(ref _analysisCount); @@ -287,8 +289,7 @@ private string CollectSessionStates() /// /// Build the analysis prompt with collected diagnostics. - /// The analyzer is instructed to REPORT issues only β€” never to autonomously create PRs. - /// This prevents prompt injection from untrusted log content directing autonomous actions. + /// The analyzer runs in autopilot and accumulates fixes on a single long-lived branch/PR. /// internal static string BuildAnalysisPrompt(string diagnostics) { @@ -306,11 +307,21 @@ Your job is to analyze the diagnostic data below and identify any issues with ru 6. **Phantom sessions** β€” (previous) or (resumed) sessions that shouldn't exist 7. **Resource leaks** β€” growing file descriptor counts, memory issues - ## How to report: - - Classify each finding as critical/warning/info - - Describe the issue, the evidence from the logs, and a recommended fix - - For code bugs, describe the root cause and which file/method to fix - - Do NOT autonomously create branches or PRs β€” report only so a human can review + ## What to do when you find issues: + + ### For code bugs you can fix: + 1. Check if branch `fix/session-analyzer-findings` already exists (`git branch -a | grep fix/session-analyzer-findings`) + 2. If it exists, check it out and pull latest β€” your previous fixes are already there + 3. If it does not exist, create it from `main` + 4. Write the fix, run tests, commit, and push to `fix/session-analyzer-findings` + 5. Check if a PR already exists for that branch (`gh pr list --head fix/session-analyzer-findings`) + 6. If a PR exists, it will automatically pick up your new commits β€” just add a comment summarizing the new fix + 7. If no PR exists, open one with a clear title and description + + **IMPORTANT**: Always reuse the SAME branch `fix/session-analyzer-findings`. Never create a new branch per finding. This keeps all fixes in one PR that accumulates over time. + + ### For stuck sessions or transient issues: + - Report them clearly but do not attempt code changes ## Current Diagnostic Data: From bec851ab9b71f6634c25ddb5e2a640c8d281f00f Mon Sep 17 00:00:00 2001 From: Shane Neuville Date: Sun, 19 Apr 2026 00:28:09 -0500 Subject: [PATCH 5/5] feat: wire up SessionAnalyzer activation via Settings toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add StartSessionAnalyzerIfEnabled() / StopSessionAnalyzerAsync() helpers to CopilotService - Call StartSessionAnalyzerIfEnabled() at end of InitializeAsync and ReconnectAsync β€” auto-starts if EnableSessionAnalyzer is true - Call StopSessionAnalyzerAsync() in ReconnectAsync teardown so the analyzer is cleanly stopped before reconnect - Add Settings UI toggle (desktop-only): checkbox to enable/disable, number input for interval (1-1440 minutes) - Toggle calls Start/Stop immediately β€” no reconnect needed - Interval changes saved quietly (take effect on next analysis cycle) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PolyPilot/Components/Pages/Settings.razor | 47 +++++++++++++++++++++++ PolyPilot/Services/CopilotService.cs | 43 +++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/PolyPilot/Components/Pages/Settings.razor b/PolyPilot/Components/Pages/Settings.razor index 41bc363e9d..f1cb433203 100644 --- a/PolyPilot/Components/Pages/Settings.razor +++ b/PolyPilot/Components/Pages/Settings.razor @@ -536,6 +536,30 @@ } + @if (PlatformHelper.IsDesktop) + { +
+
+ +

Background monitor that analyzes sessions for reliability issues and can auto-fix bugs via PR

+
+ @if (settings.EnableSessionAnalyzer) + { +
+ +
+ } +
+ } +