-
Notifications
You must be signed in to change notification settings - Fork 32
feat: perpetual session analyzer for automated reliability monitoring #621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4d37620
c1fe852
37d8781
647041d
e753c4f
bec851a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,295 @@ | ||
| using PolyPilot.Services; | ||
|
|
||
| namespace PolyPilot.Tests; | ||
|
|
||
| [Collection("BaseDir")] | ||
| 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); | ||
|
|
||
| File.WriteAllText( | ||
| Path.Combine(tempDir, "event-diagnostics.log"), | ||
| "[SEND] 'TestSession' IsProcessing=true\n[COMPLETE] 'TestSession' done\n"); | ||
|
|
||
| var copilotService = CreateService(); | ||
| 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 | ||
| { | ||
| SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir); | ||
| 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 = CreateService(); | ||
| 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 | ||
| { | ||
| SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir); | ||
| 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 = CreateService(); | ||
| var serverManager = new TestServerManager(); | ||
| var analyzer = new SessionAnalyzerService(copilotService, serverManager); | ||
|
|
||
| var diagnostics = analyzer.CollectDiagnostics(); | ||
|
|
||
| Assert.Contains("Active Session States", diagnostics); | ||
| Assert.Contains("Server Health", diagnostics); | ||
| } | ||
| finally | ||
| { | ||
| SessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir); | ||
| 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_InstructsSingleBranchPrStrategy() | ||
| { | ||
| var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data"); | ||
|
|
||
| // 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] | ||
| 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); | ||
| Assert.Equal(10 * 1024 * 1024, SessionAnalyzerService.MaxLogFileSizeBytes); | ||
| } | ||
|
|
||
| [Fact] | ||
| public void IsRunning_FalseBeforeStart() | ||
| { | ||
| var copilotService = CreateService(); | ||
| 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 = CreateService(); | ||
| var serverManager = new TestServerManager(); | ||
| var analyzer = new SessionAnalyzerService(copilotService, serverManager); | ||
|
|
||
| analyzer.Dispose(); | ||
| analyzer.Dispose(); // double dispose is safe | ||
| Assert.False(analyzer.IsRunning); | ||
| } | ||
|
|
||
| [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); | ||
| } | ||
|
|
||
| [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); | ||
| } | ||
|
|
||
| [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_UsesAutopilotMode() | ||
| { | ||
| // The analyzer runs in autopilot so it can create/update PRs | ||
| var prompt = SessionAnalyzerService.BuildAnalysisPrompt("data"); | ||
|
|
||
| Assert.Contains("Write the fix, run tests, commit", prompt); | ||
| Assert.Contains("open one with a clear title", prompt); | ||
| } | ||
|
|
||
| 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; } | ||
| 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<bool> StartServerAsync(int port, string? githubToken = null) => Task.FromResult(true); | ||
| public void StopServer() { } | ||
| public bool DetectExistingServer() => IsRunning; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -120,6 +120,7 @@ public static MauiApp CreateMauiApp() | |
| builder.Services.AddSingleton<EfficiencyAnalysisService>(); | ||
| builder.Services.AddSingleton<PrLinkService>(); | ||
| builder.Services.AddSingleton<ScheduledTaskService>(); | ||
| builder.Services.AddSingleton<SessionAnalyzerService>(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MODERATE — Feature is completely inert:
Fix: Wire up startup logic gated by |
||
|
|
||
| #if DEBUG | ||
| builder.Services.AddBlazorWebViewDeveloperTools(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 CRITICAL — Test isolation: missing
[Collection("BaseDir")]and no_polypilotDirrestore (3/3 reviewers)Three tests call
SetBaseDirForTesting(tempDir)then deletetempDir, but never restore_polypilotDirtoTestSetup.TestBaseDir. After these tests run,_polypilotDirpoints to a deleted directory, corrupting subsequent tests. Also, the class lacks[Collection("BaseDir")], enabling xUnit parallel execution races.Fix:
[Collection("BaseDir")]to the classSessionAnalyzerService.SetBaseDirForTesting(TestSetup.TestBaseDir);in eachfinallyTestSetup.Initialize()