diff --git a/PolyPilot/Services/CopilotService.cs b/PolyPilot/Services/CopilotService.cs
index 8b910031bf..3b7bee8e4b 100644
--- a/PolyPilot/Services/CopilotService.cs
+++ b/PolyPilot/Services/CopilotService.cs
@@ -34,6 +34,8 @@ public partial class CopilotService : IAsyncDisposable
private Timer? _saveUiStateDebounce;
private UiState? _pendingUiState;
private readonly object _uiStateLock = new();
+ // Keepalive ping to prevent the headless server from killing idle sessions (~35 min timeout)
+ private CancellationTokenSource? _keepaliveCts;
private readonly IChatDatabase _chatDb;
private readonly IServerManager _serverManager;
private readonly IWsBridgeClient _bridgeClient;
@@ -522,6 +524,60 @@ private static void DisposePrematureIdleSignal(SessionState? state)
try { state?.PrematureIdleSignal?.Dispose(); } catch { }
}
+ /// Ping interval to prevent the headless server from killing idle sessions.
+ /// The server has a ~35 minute idle timeout; pinging every 15 minutes keeps sessions alive.
+ internal const int KeepalivePingIntervalSeconds = 15 * 60; // 15 minutes
+
+ private void StartKeepalivePing()
+ {
+ var cts = new CancellationTokenSource();
+ var prev = Interlocked.Exchange(ref _keepaliveCts, cts);
+ if (prev != null)
+ {
+ try { prev.Cancel(); } catch { }
+ prev.Dispose();
+ }
+ _ = RunKeepalivePingAsync(cts.Token);
+ }
+
+ private void StopKeepalivePing()
+ {
+ var prev = Interlocked.Exchange(ref _keepaliveCts, null);
+ if (prev != null)
+ {
+ try { prev.Cancel(); } catch { }
+ prev.Dispose();
+ }
+ }
+
+ private async Task RunKeepalivePingAsync(CancellationToken ct)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(KeepalivePingIntervalSeconds), ct);
+ if (ct.IsCancellationRequested) break;
+
+ var client = _client;
+ if (client == null || IsDemoMode || IsRemoteMode) continue;
+
+ try
+ {
+ await client.PingAsync("keepalive", ct);
+ Debug($"[KEEPALIVE] Ping sent to headless server");
+ }
+ catch (OperationCanceledException) { break; }
+ catch (Exception ex)
+ {
+ Debug($"[KEEPALIVE] Ping failed: {ex.Message}");
+ }
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex) { Debug($"[KEEPALIVE] Loop exited: {ex.Message}"); }
+ }
+
private void Debug(string message)
{
LastDebugMessage = message;
@@ -536,6 +592,7 @@ private void Debug(string message)
message.StartsWith("[DISPATCH") || message.StartsWith("[WATCHDOG") ||
message.StartsWith("[HEALTH") || message.StartsWith("[ZERO-IDLE") ||
message.StartsWith("[PERMISSION") || message.StartsWith("[RESUME-ABORT") ||
+ message.StartsWith("[KEEPALIVE") ||
message.Contains("watchdog"))
{
try
@@ -834,6 +891,10 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default)
// Initialize any registered providers (from DI / plugin loader)
await InitializeProvidersAsync(cancellationToken);
+
+ // Start keepalive pinging to prevent server idle timeout
+ if (!IsDemoMode && !IsRemoteMode && _client != null)
+ StartKeepalivePing();
}
///
@@ -897,6 +958,7 @@ public async Task ReconnectAsync(ConnectionSettings settings, CancellationToken
_currentSettings = settings;
StopConnectivityMonitoring();
+ StopKeepalivePing();
await StopCodespaceHealthCheckAsync();
// Dispose existing sessions and client
@@ -996,6 +1058,10 @@ public async Task ReconnectAsync(ConnectionSettings settings, CancellationToken
// Re-initialize providers after reconnect
await InitializeProvidersAsync(cancellationToken);
+
+ // Start keepalive pinging to prevent server idle timeout
+ if (!IsDemoMode && !IsRemoteMode && _client != null)
+ StartKeepalivePing();
}
///
@@ -1030,6 +1096,7 @@ internal async Task TryRecoverPersistentServerAsync()
Debug("[SERVER-RECOVERY] Attempting persistent server recovery (auth/connectivity failure suspected)...");
// Stop the old server — it's running but broken (e.g., expired auth token cached in-process)
+ StopKeepalivePing();
_serverManager.StopServer();
// Wait for the old server to fully release the port
@@ -1063,6 +1130,7 @@ internal async Task TryRecoverPersistentServerAsync()
FallbackNotice = "Persistent server was automatically restarted due to repeated failures. Your sessions should work again.";
Interlocked.Exchange(ref _consecutiveWatchdogTimeouts, 0);
_lastRecoveryCompletedAt = DateTime.UtcNow;
+ StartKeepalivePing();
InvokeOnUI(() => OnStateChanged?.Invoke());
return true;
}
@@ -1100,6 +1168,7 @@ public async Task RestartServerAsync(CancellationToken cancellationToken = defau
{
Debug("[SERVER-RESTART] Restarting headless server due to native module failure...");
ServerHealthNotice = null;
+ StopKeepalivePing();
// 1. Dispose all existing sessions (they hold broken connections)
foreach (var state in _sessions.Values)
@@ -1169,6 +1238,7 @@ public async Task RestartServerAsync(CancellationToken cancellationToken = defau
await RestorePreviousSessionsAsync(cancellationToken);
FlushSaveActiveSessionsToDisk();
ReconcileOrganization();
+ StartKeepalivePing();
OnStateChanged?.Invoke();
Debug("[SERVER-RESTART] Server restart complete, all sessions restored");
@@ -4013,6 +4083,7 @@ public void ClearHistory(string name)
public async ValueTask DisposeAsync()
{
StopConnectivityMonitoring();
+ StopKeepalivePing();
await StopCodespaceHealthCheckAsync();
// Flush any pending debounced writes immediately