diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index b301859..08562f0 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -7,13 +7,32 @@ namespace ClaudeDo.Ui.Services; public record ActiveTask(string Slot, string TaskId, DateTime StartedAt); +sealed class IndefiniteRetryPolicy : IRetryPolicy +{ + private static readonly TimeSpan[] _delays = + [ + TimeSpan.Zero, + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30), + ]; + + public TimeSpan? NextRetryDelay(RetryContext retryContext) => + _delays[Math.Min(retryContext.PreviousRetryCount, _delays.Length - 1)]; +} + public partial class WorkerClient : ObservableObject, IAsyncDisposable { private readonly HubConnection _hub; + private CancellationTokenSource? _startCts; [ObservableProperty] private bool _isConnected; + [ObservableProperty] + private bool _isReconnecting; + public ObservableCollection ActiveTasks { get; } = new(); public event Action? TaskStartedEvent; @@ -26,18 +45,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable { _hub = new HubConnectionBuilder() .WithUrl(signalRUrl) - .WithAutomaticReconnect() + .WithAutomaticReconnect(new IndefiniteRetryPolicy()) .Build(); _hub.Reconnected += async _ => { - Dispatcher.UIThread.Post(() => IsConnected = true); + Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; }); await SeedActiveTasksAsync(); }; _hub.Reconnecting += _ => { - Dispatcher.UIThread.Post(() => IsConnected = false); + Dispatcher.UIThread.Post(() => { IsConnected = false; IsReconnecting = true; }); return Task.CompletedTask; }; @@ -46,6 +65,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable Dispatcher.UIThread.Post(() => { IsConnected = false; + IsReconnecting = false; ActiveTasks.Clear(); }); return Task.CompletedTask; @@ -87,22 +107,43 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable }); } - public async Task StartAsync() + public Task StartAsync() { - try + _startCts = new CancellationTokenSource(); + _ = ConnectWithRetryAsync(_startCts.Token); + return Task.CompletedTask; + } + + private async Task ConnectWithRetryAsync(CancellationToken ct) + { + var delays = new[] { 0, 2, 5, 10, 30 }; + int attempt = 0; + Dispatcher.UIThread.Post(() => IsReconnecting = true); + while (!ct.IsCancellationRequested) { - await _hub.StartAsync(); - Dispatcher.UIThread.Post(() => IsConnected = true); - await SeedActiveTasksAsync(); - } - catch - { - Dispatcher.UIThread.Post(() => IsConnected = false); + try + { + await _hub.StartAsync(ct); + Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; }); + await SeedActiveTasksAsync(); + return; + } + catch (OperationCanceledException) + { + return; + } + catch + { + var delay = delays[Math.Min(attempt++, delays.Length - 1)]; + try { await Task.Delay(TimeSpan.FromSeconds(delay), ct); } + catch (OperationCanceledException) { return; } + } } } public async Task StopAsync() { + _startCts?.Cancel(); try { await _hub.StopAsync(); } catch { /* swallow */ } } diff --git a/src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs b/src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs index 8b1dc00..792bd08 100644 --- a/src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/StatusBarViewModel.cs @@ -18,9 +18,12 @@ public partial class StatusBarViewModel : ViewModelBase worker.PropertyChanged += (_, e) => { - if (e.PropertyName == nameof(WorkerClient.IsConnected)) + if (e.PropertyName == nameof(WorkerClient.IsConnected) || + e.PropertyName == nameof(WorkerClient.IsReconnecting)) { - ConnectionStatus = worker.IsConnected ? "Online" : "Offline"; + ConnectionStatus = worker.IsConnected ? "Online" + : worker.IsReconnecting ? "Connecting..." + : "Offline"; } };