This repository was archived by the owner on May 24, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 31
feat: perpetual session analyzer for automated reliability monitoring #621
Closed
Closed
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
4d37620
feat: add SessionAnalyzerService for perpetual session monitoring
PureWeen c1fe852
fix: address all 10 review findings from expert code review
PureWeen 37d8781
Merge remote-tracking branch 'origin/main' into feat/session-analyzer
PureWeen 647041d
fix: address remaining review findings from re-review
PureWeen e753c4f
feat: restore autopilot mode with single-branch PR accumulation strategy
PureWeen bec851a
feat: wire up SessionAnalyzer activation via Settings toggle
PureWeen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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()