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