diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index b5442c2..bc67d52 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -22,8 +22,19 @@ sealed class Program var factory = services.GetRequiredService(); SchemaInitializer.Apply(factory); - BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + try + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + finally + { + // Dispose the container so WorkerClient.DisposeAsync runs — + // cancels the retry loop and closes the SignalR connection cleanly + // instead of abandoning it. + try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); } + catch { /* best effort on shutdown */ } + } } public static AppBuilder BuildAvaloniaApp() diff --git a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs index aa5cb90..3fbbd50 100644 --- a/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/TaskDetailViewModel.cs @@ -55,6 +55,10 @@ public partial class TaskDetailViewModel : ViewModelBase private string? _taskId; private string? _listId; private bool _isLoading; + // Cancels an in-flight LoadAsync when a new TaskUpdated event arrives + // before the previous load finished — prevents torn state on _taskId, + // Subtasks, Tags, etc. + private CancellationTokenSource? _loadCts; public event Action? TaskChanged; @@ -79,78 +83,98 @@ public partial class TaskDetailViewModel : ViewModelBase public async Task LoadAsync(string taskId) { + // Cancel any in-flight load so rapid TaskUpdated events don't race + // on _taskId / Subtasks / Tags. The newest caller wins. + var oldCts = _loadCts; + var cts = new CancellationTokenSource(); + _loadCts = cts; + oldCts?.Cancel(); + oldCts?.Dispose(); + var ct = cts.Token; + _taskId = taskId; LiveText = ""; _formatter = new StreamLineFormatter(); - var task = await _taskRepo.GetByIdAsync(taskId); - if (task is null) return; - - if (AvailableAgents.Count == 0) - { - var agents = await _worker.GetAgentsAsync(); - AvailableAgents.AddRange(agents); - OnPropertyChanged(nameof(AvailableAgents)); - } - - _isLoading = true; try { - _listId = task.ListId; - Title = task.Title; - Description = task.Description; - Result = task.Result; - LogPath = task.LogPath; - if (task.LogPath is not null - && task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed - && File.Exists(task.LogPath)) + var task = await _taskRepo.GetByIdAsync(taskId, ct); + if (task is null) return; + ct.ThrowIfCancellationRequested(); + + if (AvailableAgents.Count == 0) { - _formatter = new StreamLineFormatter(); - LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath)); + var agents = await _worker.GetAgentsAsync(); + ct.ThrowIfCancellationRequested(); + AvailableAgents.AddRange(agents); + OnPropertyChanged(nameof(AvailableAgents)); } - StatusText = task.Status.ToString().ToLowerInvariant(); - StatusChoice = task.Status.ToString(); - CommitType = task.CommitType; - ModelChoice = task.Model is not null - ? ListEditorViewModel.ModelIdToDisplay(task.Model) - : "(list default)"; - SystemPromptOverride = task.SystemPrompt; - if (task.AgentPath is not null) + + _isLoading = true; + try { - var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath); - if (match is null) + _listId = task.ListId; + Title = task.Title; + Description = task.Description; + Result = task.Result; + LogPath = task.LogPath; + if (task.LogPath is not null + && task.Status is Data.Models.TaskStatus.Done or Data.Models.TaskStatus.Failed + && File.Exists(task.LogPath)) { - match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath); - AvailableAgents.Add(match); - OnPropertyChanged(nameof(AvailableAgents)); + _formatter = new StreamLineFormatter(); + LiveText = await Task.Run(() => _formatter.FormatFile(task.LogPath), ct); + } + StatusText = task.Status.ToString().ToLowerInvariant(); + StatusChoice = task.Status.ToString(); + CommitType = task.CommitType; + ModelChoice = task.Model is not null + ? ListEditorViewModel.ModelIdToDisplay(task.Model) + : "(list default)"; + SystemPromptOverride = task.SystemPrompt; + if (task.AgentPath is not null) + { + var match = AvailableAgents.FirstOrDefault(a => a.Path == task.AgentPath); + if (match is null) + { + match = new AgentInfo(Path.GetFileNameWithoutExtension(task.AgentPath), "(external)", task.AgentPath); + AvailableAgents.Add(match); + OnPropertyChanged(nameof(AvailableAgents)); + } + SelectedAgent = match; + } + else + { + SelectedAgent = null; + } + + Tags.Clear(); + var tags = await _taskRepo.GetTagsAsync(taskId, ct); + foreach (var tag in tags) + Tags.Add(tag); + + // Tear down old subtask subscriptions before replacing them. + foreach (var old in Subtasks) old.PropertyChanged -= OnSubtaskPropertyChanged; + Subtasks.Clear(); + var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId, ct); + foreach (var s in subtasks) + { + var vm = SubtaskItemViewModel.From(s); + vm.PropertyChanged += OnSubtaskPropertyChanged; + Subtasks.Add(vm); } - SelectedAgent = match; } - else + finally { - SelectedAgent = null; + _isLoading = false; } - Tags.Clear(); - var tags = await _taskRepo.GetTagsAsync(taskId); - foreach (var tag in tags) - Tags.Add(tag); - - Subtasks.Clear(); - var subtasks = await _subtaskRepo.GetByTaskIdAsync(taskId); - foreach (var s in subtasks) - { - var vm = SubtaskItemViewModel.From(s); - vm.PropertyChanged += OnSubtaskPropertyChanged; - Subtasks.Add(vm); - } + await LoadWorktreeAsync(taskId); } - finally + catch (OperationCanceledException) { - _isLoading = false; + // Superseded by a newer LoadAsync — nothing to do. } - - await LoadWorktreeAsync(taskId); } public async Task SaveAsync() @@ -236,15 +260,23 @@ public partial class TaskDetailViewModel : ViewModelBase { if (_isLoading || sender is not SubtaskItemViewModel vm || string.IsNullOrEmpty(vm.Id)) return; if (e.PropertyName is not (nameof(SubtaskItemViewModel.Title) or nameof(SubtaskItemViewModel.Completed))) return; - await _subtaskRepo.UpdateAsync(new SubtaskEntity + try { - Id = vm.Id, - TaskId = _taskId ?? "", - Title = vm.Title, - Completed = vm.Completed, - OrderNum = Subtasks.IndexOf(vm), - CreatedAt = DateTime.UtcNow, - }); + await _subtaskRepo.UpdateAsync(new SubtaskEntity + { + Id = vm.Id, + TaskId = _taskId ?? "", + Title = vm.Title, + Completed = vm.Completed, + OrderNum = Subtasks.IndexOf(vm), + CreatedAt = DateTime.UtcNow, + }); + } + catch (Exception ex) + { + // async void must never throw — surface via Debug. + Debug.WriteLine($"[TaskDetailViewModel] Subtask update failed for {vm.Id}: {ex}"); + } } public void SetAgentFromPath(string path) @@ -261,6 +293,11 @@ public partial class TaskDetailViewModel : ViewModelBase public void Clear() { + // Cancel any load in flight so it doesn't resurrect state after Clear. + _loadCts?.Cancel(); + _loadCts?.Dispose(); + _loadCts = null; + _taskId = null; _listId = null; Title = ""; @@ -408,12 +445,28 @@ public partial class TaskDetailViewModel : ViewModelBase private async void OnWorktreeUpdated(string taskId) { if (taskId != _taskId) return; - await LoadWorktreeAsync(taskId); + try + { + await LoadWorktreeAsync(taskId); + } + catch (Exception ex) + { + // async void must never throw. + Debug.WriteLine($"[TaskDetailViewModel] OnWorktreeUpdated failed for {taskId}: {ex}"); + } } private async void OnTaskUpdated(string taskId) { if (taskId != _taskId) return; - await LoadAsync(taskId); + try + { + await LoadAsync(taskId); + } + catch (Exception ex) + { + // async void must never throw. + Debug.WriteLine($"[TaskDetailViewModel] OnTaskUpdated failed for {taskId}: {ex}"); + } } }