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);
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<ActiveTask> ActiveTasks { get; } = new();
public event Action<string, string, DateTime>? 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 */ }
}