fix(ui): auto-reconnect worker connection with retry backoff

Add IndefiniteRetryPolicy for WithAutomaticReconnect, wrap initial
StartAsync in a retry loop so the UI keeps retrying when Worker is
offline at startup, and expose IsReconnecting to StatusBar
("Connecting..." / "Online" / "Offline").
This commit is contained in:
Mika Kuns
2026-04-13 14:40:36 +02:00
parent 48e4aabeb1
commit c6522cf8c1
2 changed files with 58 additions and 14 deletions

View File

@@ -7,13 +7,32 @@ namespace ClaudeDo.Ui.Services;
public record ActiveTask(string Slot, string TaskId, DateTime StartedAt); 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 public partial class WorkerClient : ObservableObject, IAsyncDisposable
{ {
private readonly HubConnection _hub; private readonly HubConnection _hub;
private CancellationTokenSource? _startCts;
[ObservableProperty] [ObservableProperty]
private bool _isConnected; private bool _isConnected;
[ObservableProperty]
private bool _isReconnecting;
public ObservableCollection<ActiveTask> ActiveTasks { get; } = new(); public ObservableCollection<ActiveTask> ActiveTasks { get; } = new();
public event Action<string, string, DateTime>? TaskStartedEvent; public event Action<string, string, DateTime>? TaskStartedEvent;
@@ -26,18 +45,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
{ {
_hub = new HubConnectionBuilder() _hub = new HubConnectionBuilder()
.WithUrl(signalRUrl) .WithUrl(signalRUrl)
.WithAutomaticReconnect() .WithAutomaticReconnect(new IndefiniteRetryPolicy())
.Build(); .Build();
_hub.Reconnected += async _ => _hub.Reconnected += async _ =>
{ {
Dispatcher.UIThread.Post(() => IsConnected = true); Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
await SeedActiveTasksAsync(); await SeedActiveTasksAsync();
}; };
_hub.Reconnecting += _ => _hub.Reconnecting += _ =>
{ {
Dispatcher.UIThread.Post(() => IsConnected = false); Dispatcher.UIThread.Post(() => { IsConnected = false; IsReconnecting = true; });
return Task.CompletedTask; return Task.CompletedTask;
}; };
@@ -46,6 +65,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() =>
{ {
IsConnected = false; IsConnected = false;
IsReconnecting = false;
ActiveTasks.Clear(); ActiveTasks.Clear();
}); });
return Task.CompletedTask; return Task.CompletedTask;
@@ -87,22 +107,43 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
}); });
} }
public async Task StartAsync() public Task StartAsync()
{
_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)
{ {
try try
{ {
await _hub.StartAsync(); await _hub.StartAsync(ct);
Dispatcher.UIThread.Post(() => IsConnected = true); Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
await SeedActiveTasksAsync(); await SeedActiveTasksAsync();
return;
}
catch (OperationCanceledException)
{
return;
} }
catch catch
{ {
Dispatcher.UIThread.Post(() => IsConnected = false); var delay = delays[Math.Min(attempt++, delays.Length - 1)];
try { await Task.Delay(TimeSpan.FromSeconds(delay), ct); }
catch (OperationCanceledException) { return; }
}
} }
} }
public async Task StopAsync() public async Task StopAsync()
{ {
_startCts?.Cancel();
try { await _hub.StopAsync(); } catch { /* swallow */ } try { await _hub.StopAsync(); } catch { /* swallow */ }
} }

View File

@@ -18,9 +18,12 @@ public partial class StatusBarViewModel : ViewModelBase
worker.PropertyChanged += (_, e) => 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";
} }
}; };