diff --git a/PolyPilot.Tests/PermissionDenialDetectionTests.cs b/PolyPilot.Tests/PermissionDenialDetectionTests.cs index 7fc5057440..bddab3e222 100644 --- a/PolyPilot.Tests/PermissionDenialDetectionTests.cs +++ b/PolyPilot.Tests/PermissionDenialDetectionTests.cs @@ -318,3 +318,89 @@ private static string GetRepoRoot() return dir ?? throw new InvalidOperationException("Could not find repo root"); } } + +/// +/// Tests for CopilotService.IsInitializationError — the helper that detects +/// "Service not initialized" exceptions so worker dispatch can retry them +/// with a lazy re-init attempt instead of failing immediately (PR #422). +/// +public class InitializationErrorDetectionTests +{ + [Fact] + public void IsInitializationError_ServiceNotInitialized_ReturnsTrue() + { + var ex = new InvalidOperationException("Service not initialized. Call InitializeAsync first."); + Assert.True(CopilotService.IsInitializationError(ex)); + } + + [Fact] + public void IsInitializationError_ClientNotInitialized_ReturnsTrue() + { + var ex = new InvalidOperationException("Client is not initialized"); + Assert.True(CopilotService.IsInitializationError(ex)); + } + + [Theory] + [InlineData("Service not initialized. Call InitializeAsync first.")] + [InlineData("Client is not initialized")] + [InlineData("NOT INITIALIZED")] // case-insensitive + public void IsInitializationError_Variants_ReturnsTrue(string message) + { + var ex = new InvalidOperationException(message); + Assert.True(CopilotService.IsInitializationError(ex)); + } + + [Fact] + public void IsInitializationError_ConnectionRefused_ReturnsFalse() + { + // Connection errors are handled by IsConnectionError, not IsInitializationError + var ex = new InvalidOperationException("connection refused"); + Assert.False(CopilotService.IsInitializationError(ex)); + } + + [Fact] + public void IsInitializationError_WrongExceptionType_ReturnsFalse() + { + // Only InvalidOperationException matches — not base Exception + var ex = new Exception("Service not initialized"); + Assert.False(CopilotService.IsInitializationError(ex)); + } + + [Fact] + public void IsInitializationError_ArbitraryInvalidOperation_ReturnsFalse() + { + var ex = new InvalidOperationException("Session 'foo' already exists."); + Assert.False(CopilotService.IsInitializationError(ex)); + } + + [Fact] + public void IsInitializationError_HelperIsInternal_CanBeTestedViaPublicSurface() + { + // The helper is internal — verify it's accessible from tests (InternalsVisibleTo). + // Also verify the helper exists in CopilotService.Utilities.cs at the right location. + var repoRoot = GetRepoRoot(); + var source = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot", "Services", "CopilotService.Utilities.cs")); + Assert.Contains("internal static bool IsInitializationError(Exception ex)", source); + Assert.Contains("not initialized", source); + } + + [Fact] + public void ExecuteWorkerAsync_RetryGate_IncludesInitializationError() + { + // Structural: the retry catch in ExecuteWorkerAsync must check IsInitializationError + // so "Service not initialized" triggers a retry with lazy re-init, not an immediate fail. + var repoRoot = GetRepoRoot(); + var source = File.ReadAllText(Path.Combine(repoRoot, "PolyPilot", "Services", "CopilotService.Organization.cs")); + Assert.Contains("IsInitializationError(ex)", source); + // The retry gate must combine both checks + Assert.Contains("IsConnectionError(ex) || IsInitializationError(ex)", source); + } + + private static string GetRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (dir != null && !File.Exists(Path.Combine(dir, "PolyPilot.slnx"))) + dir = Path.GetDirectoryName(dir); + return dir ?? throw new InvalidOperationException("Could not find repo root"); + } +} diff --git a/PolyPilot/Services/CopilotService.Organization.cs b/PolyPilot/Services/CopilotService.Organization.cs index 907f85b3ad..2df2e8f7db 100644 --- a/PolyPilot/Services/CopilotService.Organization.cs +++ b/PolyPilot/Services/CopilotService.Organization.cs @@ -2247,9 +2247,17 @@ private async Task ExecuteWorkerAsync(string workerName, string ta Debug($"[DISPATCH] Worker '{workerName}' completed (response len={response?.Length ?? 0}, elapsed={sw.Elapsed.TotalSeconds:F1}s)"); return new WorkerResult(workerName, response, true, null, sw.Elapsed); } - catch (Exception ex) when (attempt < maxRetries && IsConnectionError(ex)) + catch (Exception ex) when (attempt < maxRetries && (IsConnectionError(ex) || IsInitializationError(ex))) { Debug($"[DISPATCH] Worker '{workerName}' attempt {attempt} failed with {ex.GetType().Name} — retrying in 2s"); + // If the service became uninitialized (e.g., a concurrent worker's connection + // error set IsInitialized=false), attempt lazy re-init before the next try. + if (!IsInitialized || _client == null) + { + Debug($"[DISPATCH] Worker '{workerName}': service uninitialized — attempting lazy re-init before retry"); + try { await InitializeAsync(cancellationToken); } + catch (Exception reinitEx) { Debug($"[DISPATCH] Worker '{workerName}': lazy re-init failed: {reinitEx.Message}"); } + } await Task.Delay(2000, cancellationToken); continue; } diff --git a/PolyPilot/Services/CopilotService.Utilities.cs b/PolyPilot/Services/CopilotService.Utilities.cs index f2b8190ede..541951c990 100644 --- a/PolyPilot/Services/CopilotService.Utilities.cs +++ b/PolyPilot/Services/CopilotService.Utilities.cs @@ -470,6 +470,14 @@ internal static bool IsConnectionError(Exception ex) return ex.InnerException != null && IsConnectionError(ex.InnerException); } + /// + /// Returns true if the exception indicates the Copilot service is not yet initialized + /// (e.g., _client is null after a previous connection failure). These are retryable in + /// multi-agent worker dispatch with a lazy re-init attempt before the next retry. + /// + internal static bool IsInitializationError(Exception ex) => + ex is InvalidOperationException && ex.Message.Contains("not initialized", StringComparison.OrdinalIgnoreCase); + /// /// Returns true if the exception indicates the CLI server process is dead /// (e.g., Process.HasExited throws because the Process handle was never started