diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 0a13dea0..2df99684 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -20,14 +20,24 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable public ListsIslandViewModel? Lists { get; } public TasksIslandViewModel? Tasks { get; } public DetailsIslandViewModel? Details { get; } - public IWorkerClient? Worker { get; } + public IWorkerClient? Worker { get; internal set; } public MissionControlViewModel? MissionControl { get; } public UsagePillViewModel? UsagePill { get; } public UpdateCheckService UpdateCheck => _updateCheck; + // Stable opKey the six Lifecycle/*Recovery startup sweeps broadcast under (see + // OperationProgressOpKeys.StartupRecovery on the worker side — not shared across the + // SignalR wire, so the literal is duplicated here on purpose). + private const string StartupRecoveryOpKey = "startup-recovery"; + + // Last startup-recovery text received this connection cycle, shown in place of the generic + // "connecting" label. Cleared once actually connected so a later transient reconnect (not a + // fresh worker boot) doesn't replay stale recovery text. + private string? _startupRecoveryText; + public string ConnectionText => Worker?.IsConnected == true ? Loc.T("vm.connection.online") - : Worker?.IsReconnecting == true ? Loc.T("vm.connection.connecting") + : Worker?.IsReconnecting == true ? (_startupRecoveryText ?? Loc.T("vm.connection.connecting")) : Loc.T("vm.connection.offline"); public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true; @@ -228,6 +238,18 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable } public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId); + // Wired to Worker.OperationProgressEvent; also called directly by tests. A foreign opKey + // (merge phases, worktree cleanup, ...) is left untouched — this only tracks the + // startup-recovery channel. + public void OnOperationProgress(string opKey, string phase, int current, int total) + { + if (opKey != StartupRecoveryOpKey) return; + _startupRecoveryText = total > 0 + ? $"{Loc.T("ops.worker.startupRecovery")} ({current}/{total})" + : Loc.T("ops.worker.startupRecovery"); + OnPropertyChanged(nameof(ConnectionText)); + } + private void ClearExternalMergeConflict(string planningTaskId) { _externalMergeConflicts.Remove(planningTaskId); @@ -358,11 +380,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable { if (e.PropertyName is nameof(IWorkerClient.IsConnected) or nameof(IWorkerClient.IsReconnecting)) { + if (Worker.IsConnected) _startupRecoveryText = null; OnPropertyChanged(nameof(ConnectionText)); OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(CanOpenWorkerConnectionHelp)); } }; + Worker.OperationProgressEvent += OnOperationProgress; Worker.WorkerLogReceivedEvent += OnWorkerLogReceived; Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync(); Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict; @@ -410,6 +434,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable public void Dispose() { + if (Worker is not null) Worker.OperationProgressEvent -= OnOperationProgress; _clearTimer.Stop(); _clearTimer.Dispose(); _connectTimer.Stop(); diff --git a/src/ClaudeDo.Worker/Hub/OperationProgressOpKeys.cs b/src/ClaudeDo.Worker/Hub/OperationProgressOpKeys.cs new file mode 100644 index 00000000..f183ab41 --- /dev/null +++ b/src/ClaudeDo.Worker/Hub/OperationProgressOpKeys.cs @@ -0,0 +1,8 @@ +namespace ClaudeDo.Worker.Hub; + +/// Stable, non-task-bound opKeys for . +public static class OperationProgressOpKeys +{ + /// The six startup-only Lifecycle/*Recovery hosted services. + public const string StartupRecovery = "startup-recovery"; +} diff --git a/src/ClaudeDo.Worker/Lifecycle/AttachmentOrphanRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/AttachmentOrphanRecovery.cs index 596076c4..508419cb 100644 --- a/src/ClaudeDo.Worker/Lifecycle/AttachmentOrphanRecovery.cs +++ b/src/ClaudeDo.Worker/Lifecycle/AttachmentOrphanRecovery.cs @@ -1,4 +1,5 @@ using ClaudeDo.Data; +using ClaudeDo.Worker.Hub; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Lifecycle; @@ -8,17 +9,22 @@ namespace ClaudeDo.Worker.Lifecycle; /// public sealed class AttachmentOrphanRecovery : IHostedService { + public const string Phase = "attachments"; + private readonly IDbContextFactory _dbFactory; private readonly AttachmentStore _store; + private readonly HubBroadcaster _broadcaster; private readonly ILogger _logger; public AttachmentOrphanRecovery( IDbContextFactory dbFactory, AttachmentStore store, + HubBroadcaster broadcaster, ILogger logger) { _dbFactory = dbFactory; _store = store; + _broadcaster = broadcaster; _logger = logger; } @@ -28,6 +34,7 @@ public sealed class AttachmentOrphanRecovery : IHostedService if (taskIds.Count == 0) { _logger.LogInformation("Attachment orphan recovery: no attachment directories found"); + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, 0, 0); return; } @@ -45,6 +52,8 @@ public sealed class AttachmentOrphanRecovery : IHostedService _logger.LogWarning("Attachment orphan recovery: removed {Count} orphaned attachment director(ies)", orphans.Count); else _logger.LogInformation("Attachment orphan recovery: no orphaned attachment directories found"); + + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, taskIds.Count, taskIds.Count); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/ClaudeDo.Worker/Lifecycle/LegacyWorktreeFolderRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/LegacyWorktreeFolderRecovery.cs index 6b685f3f..6a91aa34 100644 --- a/src/ClaudeDo.Worker/Lifecycle/LegacyWorktreeFolderRecovery.cs +++ b/src/ClaudeDo.Worker/Lifecycle/LegacyWorktreeFolderRecovery.cs @@ -1,6 +1,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Lifecycle; @@ -13,16 +14,21 @@ namespace ClaudeDo.Worker.Lifecycle; /// public sealed class LegacyWorktreeFolderRecovery : IHostedService { + public const string Phase = "legacy-worktrees"; + private const string LegacyFolderName = ".claudedo-worktrees"; private readonly IDbContextFactory _dbFactory; + private readonly HubBroadcaster _broadcaster; private readonly ILogger _logger; public LegacyWorktreeFolderRecovery( IDbContextFactory dbFactory, + HubBroadcaster broadcaster, ILogger logger) { _dbFactory = dbFactory; + _broadcaster = broadcaster; _logger = logger; } @@ -40,6 +46,8 @@ public sealed class LegacyWorktreeFolderRecovery : IHostedService LegacyFolderName, affected.Count, string.Join(", ", affected)); else _logger.LogInformation("Legacy worktree folder recovery: no lists affected"); + + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, lists.Count, lists.Count); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs index fd67c02b..7c543975 100644 --- a/src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs +++ b/src/ClaudeDo.Worker/Lifecycle/OrphanRecovery.cs @@ -1,5 +1,6 @@ using ClaudeDo.Data; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Lifecycle; @@ -12,14 +13,19 @@ namespace ClaudeDo.Worker.Lifecycle; /// public sealed class OrphanRecovery : IHostedService { + public const string Phase = "orphaned-children"; + private readonly IDbContextFactory _dbFactory; + private readonly HubBroadcaster _broadcaster; private readonly ILogger _logger; public OrphanRecovery( IDbContextFactory dbFactory, + HubBroadcaster broadcaster, ILogger logger) { _dbFactory = dbFactory; + _broadcaster = broadcaster; _logger = logger; } @@ -32,6 +38,8 @@ public sealed class OrphanRecovery : IHostedService _logger.LogWarning("Orphan recovery: dequeued {Count} stuck child task(s)", dequeued); else _logger.LogInformation("Orphan recovery: no stuck child tasks found"); + + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, 0, 0); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs index 33d88c02..a100f135 100644 --- a/src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs +++ b/src/ClaudeDo.Worker/Lifecycle/PlanningLineageRecovery.cs @@ -1,5 +1,6 @@ using ClaudeDo.Data; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Worker.Lifecycle; @@ -13,17 +14,22 @@ namespace ClaudeDo.Worker.Lifecycle; /// public sealed class PlanningLineageRecovery : IHostedService { + public const string Phase = "planning-lineage"; + private readonly IDbContextFactory _dbFactory; private readonly string _sessionsRoot; + private readonly HubBroadcaster _broadcaster; private readonly ILogger _logger; public PlanningLineageRecovery( IDbContextFactory dbFactory, string sessionsRoot, + HubBroadcaster broadcaster, ILogger logger) { _dbFactory = dbFactory; _sessionsRoot = sessionsRoot; + _broadcaster = broadcaster; _logger = logger; } @@ -32,6 +38,7 @@ public sealed class PlanningLineageRecovery : IHostedService if (!Directory.Exists(_sessionsRoot)) { _logger.LogInformation("Planning lineage recovery: sessions directory missing, nothing to scan"); + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, 0, 0); return; } @@ -39,6 +46,7 @@ public sealed class PlanningLineageRecovery : IHostedService if (folders.Length == 0) { _logger.LogInformation("Planning lineage recovery: no session folders"); + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, 0, 0); return; } @@ -63,6 +71,8 @@ public sealed class PlanningLineageRecovery : IHostedService if (restored == 0) _logger.LogInformation("Planning lineage recovery: no candidates"); + + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, folders.Length, folders.Length); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/ClaudeDo.Worker/Lifecycle/PromptFileRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/PromptFileRecovery.cs index 5dae6917..33c84b78 100644 --- a/src/ClaudeDo.Worker/Lifecycle/PromptFileRecovery.cs +++ b/src/ClaudeDo.Worker/Lifecycle/PromptFileRecovery.cs @@ -1,4 +1,5 @@ using ClaudeDo.Data; +using ClaudeDo.Worker.Hub; namespace ClaudeDo.Worker.Lifecycle; @@ -10,16 +11,20 @@ namespace ClaudeDo.Worker.Lifecycle; /// public sealed class PromptFileRecovery : IHostedService { + public const string Phase = "prompt-files"; + private readonly ILogger _logger; + private readonly HubBroadcaster _broadcaster; private readonly string? _root; - public PromptFileRecovery(ILogger logger, string? root = null) + public PromptFileRecovery(ILogger logger, HubBroadcaster broadcaster, string? root = null) { _logger = logger; + _broadcaster = broadcaster; _root = root; } - public Task StartAsync(CancellationToken cancellationToken) + public async Task StartAsync(CancellationToken cancellationToken) { PromptFiles.ReconcileStaleDefaults(_root); @@ -27,7 +32,8 @@ public sealed class PromptFileRecovery : IHostedService if (orphans.Count == 0) { _logger.LogInformation("Prompt file recovery: no orphaned prompt files found"); - return Task.CompletedTask; + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, 0, 0); + return; } foreach (var orphan_path in orphans) @@ -40,7 +46,7 @@ public sealed class PromptFileRecovery : IHostedService orphan_path); } - return Task.CompletedTask; + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, orphans.Count, orphans.Count); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/ClaudeDo.Worker/Lifecycle/StaleTaskRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/StaleTaskRecovery.cs index d39af7a1..d8d84aeb 100644 --- a/src/ClaudeDo.Worker/Lifecycle/StaleTaskRecovery.cs +++ b/src/ClaudeDo.Worker/Lifecycle/StaleTaskRecovery.cs @@ -1,15 +1,20 @@ +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.State; namespace ClaudeDo.Worker.Lifecycle; public sealed class StaleTaskRecovery : IHostedService { + public const string Phase = "stale-tasks"; + private readonly ITaskStateService _state; + private readonly HubBroadcaster _broadcaster; private readonly ILogger _logger; - public StaleTaskRecovery(ITaskStateService state, ILogger logger) + public StaleTaskRecovery(ITaskStateService state, HubBroadcaster broadcaster, ILogger logger) { _state = state; + _broadcaster = broadcaster; _logger = logger; } @@ -20,6 +25,8 @@ public sealed class StaleTaskRecovery : IHostedService _logger.LogWarning("Stale task recovery: flipped {Count} running task(s) to failed", flipped); else _logger.LogInformation("Stale task recovery: no stale tasks found"); + + await _broadcaster.OperationProgress(OperationProgressOpKeys.StartupRecovery, Phase, 0, 0); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index ff7f7c8a..2ec1916d 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -174,6 +174,7 @@ builder.Services.AddSingleton(sp => builder.Services.AddHostedService(sp => new PlanningLineageRecovery( sp.GetRequiredService>(), planningSessionsDir, + sp.GetRequiredService(), sp.GetRequiredService>())); builder.Services.AddSingleton(sp => new WindowsTerminalLauncher("wt.exe", cfg.ClaudeBin)); diff --git a/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelStartupRecoveryTests.cs b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelStartupRecoveryTests.cs new file mode 100644 index 00000000..cbbc1940 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelStartupRecoveryTests.cs @@ -0,0 +1,56 @@ +using ClaudeDo.Ui.ViewModels; +using Xunit; + +namespace ClaudeDo.Ui.Tests; + +// Covers C2: the six Lifecycle/*Recovery startup sweeps broadcast OperationProgress under the +// stable "startup-recovery" opKey; the shell replaces the generic "connecting" text with it +// while the worker is reconnecting. +public class IslandsShellViewModelStartupRecoveryTests +{ + private sealed class ReconnectingWorkerClient : StubWorkerClient + { + public override bool IsReconnecting => true; + } + + [Fact] + public void StartupRecoveryProgress_ReplacesConnectingText() + { + var vm = new IslandsShellViewModel(); + var worker = new ReconnectingWorkerClient(); + vm.Worker = worker; + worker.OperationProgressEvent += vm.OnOperationProgress; + + worker.RaiseOperationProgress("startup-recovery", "attachments", 3, 6); + + Assert.Contains("3/6", vm.ConnectionText); + } + + [Fact] + public void StartupRecoveryProgress_WithoutTotal_ShowsLabelOnly() + { + var vm = new IslandsShellViewModel(); + var worker = new ReconnectingWorkerClient(); + vm.Worker = worker; + worker.OperationProgressEvent += vm.OnOperationProgress; + + worker.RaiseOperationProgress("startup-recovery", "stale-tasks", 0, 0); + + Assert.DoesNotContain("/", vm.ConnectionText); + Assert.NotEqual(string.Empty, vm.ConnectionText); + } + + [Fact] + public void ForeignOpKey_DoesNotChangeConnectionText() + { + var vm = new IslandsShellViewModel(); + var worker = new ReconnectingWorkerClient(); + vm.Worker = worker; + worker.OperationProgressEvent += vm.OnOperationProgress; + var before = vm.ConnectionText; + + worker.RaiseOperationProgress("merge:task-123", "merging", 0, 0); + + Assert.Equal(before, vm.ConnectionText); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs index 4480d47e..baeff322 100644 --- a/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs @@ -1,5 +1,6 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; @@ -49,14 +50,18 @@ public sealed class AttachmentOrphanRecoveryTests : IDisposable Directory.CreateDirectory(liveDir); Directory.CreateDirectory(orphanDir); + var hubContext = new CapturingHubContext(); var sut = new AttachmentOrphanRecovery( _db.CreateFactory(), store, + new HubBroadcaster(hubContext), NullLogger.Instance); await sut.StartAsync(CancellationToken.None); Assert.True(Directory.Exists(liveDir), "Live task dir should be kept"); Assert.False(Directory.Exists(orphanDir), "Orphan dir should be deleted"); + Assert.Contains(hubContext.Proxy.Calls, c => + c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); } [Fact] @@ -66,10 +71,15 @@ public sealed class AttachmentOrphanRecoveryTests : IDisposable var missingRoot = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}"); var store = new AttachmentStore(missingRoot); + var hubContext = new CapturingHubContext(); var sut = new AttachmentOrphanRecovery( _db.CreateFactory(), store, + new HubBroadcaster(hubContext), NullLogger.Instance); await sut.StartAsync(CancellationToken.None); // must not throw + + Assert.Contains(hubContext.Proxy.Calls, c => + c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); } } diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/LegacyWorktreeFolderRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/LegacyWorktreeFolderRecoveryTests.cs index 1248dd1c..9c560bd9 100644 --- a/tests/ClaudeDo.Worker.Tests/Lifecycle/LegacyWorktreeFolderRecoveryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/LegacyWorktreeFolderRecoveryTests.cs @@ -1,4 +1,5 @@ using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; @@ -63,9 +64,14 @@ public sealed class LegacyWorktreeFolderRecoveryTests : IDisposable [Fact] public async Task StartAsync_NoLists_DoesNotThrow() { - var sut = new LegacyWorktreeFolderRecovery(_db.CreateFactory(), NullLogger.Instance); + var hubContext = new CapturingHubContext(); + var sut = new LegacyWorktreeFolderRecovery( + _db.CreateFactory(), new HubBroadcaster(hubContext), NullLogger.Instance); await sut.StartAsync(CancellationToken.None); await sut.StopAsync(CancellationToken.None); + + Assert.Contains(hubContext.Proxy.Calls, c => + c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); } } diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/OrphanRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/OrphanRecoveryTests.cs new file mode 100644 index 00000000..2b0a3705 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/OrphanRecoveryTests.cs @@ -0,0 +1,27 @@ +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Lifecycle; + +public sealed class OrphanRecoveryTests : IDisposable +{ + private readonly DbFixture _db = new(); + + public void Dispose() => _db.Dispose(); + + [Fact] + public async Task StartAsync_NoOrphans_BroadcastsStartupRecoveryProgress() + { + var hubContext = new CapturingHubContext(); + var sut = new OrphanRecovery( + _db.CreateFactory(), new HubBroadcaster(hubContext), NullLogger.Instance); + + await sut.StartAsync(CancellationToken.None); + await sut.StopAsync(CancellationToken.None); + + Assert.Contains(hubContext.Proxy.Calls, c => + c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/PlanningLineageRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/PlanningLineageRecoveryTests.cs new file mode 100644 index 00000000..01e73866 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/PlanningLineageRecoveryTests.cs @@ -0,0 +1,52 @@ +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Lifecycle; + +public sealed class PlanningLineageRecoveryTests : IDisposable +{ + private readonly DbFixture _db = new(); + + public void Dispose() => _db.Dispose(); + + [Fact] + public async Task StartAsync_SessionsDirMissing_BroadcastsStartupRecoveryProgress() + { + var missingRoot = Path.Combine(Path.GetTempPath(), $"claudedo_planning_sessions_{Guid.NewGuid():N}"); + var hubContext = new CapturingHubContext(); + var sut = new PlanningLineageRecovery( + _db.CreateFactory(), missingRoot, new HubBroadcaster(hubContext), + NullLogger.Instance); + + await sut.StartAsync(CancellationToken.None); + await sut.StopAsync(CancellationToken.None); + + Assert.Contains(hubContext.Proxy.Calls, c => + c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); + } + + [Fact] + public async Task StartAsync_NoSessionFolders_BroadcastsStartupRecoveryProgress() + { + var root = Path.Combine(Path.GetTempPath(), $"claudedo_planning_sessions_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var hubContext = new CapturingHubContext(); + var sut = new PlanningLineageRecovery( + _db.CreateFactory(), root, new HubBroadcaster(hubContext), + NullLogger.Instance); + + await sut.StartAsync(CancellationToken.None); + + Assert.Contains(hubContext.Proxy.Calls, c => + c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/PromptFileRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/PromptFileRecoveryTests.cs index 636b7376..3da5f811 100644 --- a/tests/ClaudeDo.Worker.Tests/Lifecycle/PromptFileRecoveryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/PromptFileRecoveryTests.cs @@ -1,6 +1,8 @@ using System.Text.Json; using ClaudeDo.Data; +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -49,7 +51,8 @@ public sealed class PromptFileRecoveryTests var orphanPath = Path.Combine(root, "agent.md"); File.WriteAllText(orphanPath, "leftover"); - var sut = new PromptFileRecovery(NullLogger.Instance, root); + var hubContext = new CapturingHubContext(); + var sut = new PromptFileRecovery(NullLogger.Instance, new HubBroadcaster(hubContext), root); await sut.StartAsync(CancellationToken.None); @@ -57,6 +60,8 @@ public sealed class PromptFileRecoveryTests Assert.True(File.Exists(PromptFiles.PathFor(PromptKind.System, root)), "Real edit must survive"); Assert.False(File.Exists(orphanPath), "Orphan must be moved out of the prompts root"); Assert.True(File.Exists(Path.Combine(root, "_orphans", "agent.md")), "Orphan must be quarantined, not deleted"); + Assert.Contains(hubContext.Proxy.Calls, c => + c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); await sut.StopAsync(CancellationToken.None); // must not throw } @@ -77,7 +82,7 @@ public sealed class PromptFileRecoveryTests File.WriteAllText(orphanPath, "my old customized triage+wait+merge prompt"); var logger = new CapturingLogger(); - var sut = new PromptFileRecovery(logger, root); + var sut = new PromptFileRecovery(logger, new HubBroadcaster(new CapturingHubContext()), root); await sut.StartAsync(CancellationToken.None); diff --git a/tests/ClaudeDo.Worker.Tests/Services/StaleTaskRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Services/StaleTaskRecoveryTests.cs index 20eb7d0d..bff982b3 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/StaleTaskRecoveryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/StaleTaskRecoveryTests.cs @@ -1,6 +1,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; @@ -55,7 +56,8 @@ public sealed class StaleTaskRecoveryTests : IDisposable await _tasks.AddAsync(queued); var built = TaskStateServiceBuilder.Build(_db.CreateFactory()); - var recovery = new StaleTaskRecovery(built.State, NullLogger.Instance); + var hubContext = new CapturingHubContext(); + var recovery = new StaleTaskRecovery(built.State, new HubBroadcaster(hubContext), NullLogger.Instance); await recovery.StartAsync(CancellationToken.None); var r = await _tasks.GetByIdAsync(running.Id); @@ -64,5 +66,8 @@ public sealed class StaleTaskRecoveryTests : IDisposable var q = await _tasks.GetByIdAsync(queued.Id); Assert.Equal(TaskStatus.Queued, q!.Status); + + Assert.Contains(hubContext.Proxy.Calls, c => + c.Method == "OperationProgress" && (string)c.Args[0]! == "startup-recovery"); } }