refactor(planning): dequeue orphans instead of promoting, restore lost lineage

Three behavioral changes around stuck planning subtasks:

- OrphanRecovery no longer clears ParentTaskId. Queued children of a
  parent that is not in a planning phase are dequeued (Status: Queued
  -> Idle, BlockedByTaskId cleared) but stay attached to the parent so
  the historical lineage is preserved.
- DiscardPlanningAsync stops promoting terminal (Done/Failed/Cancelled)
  children to top-level for the same reason - they remain ChildTasks of
  the (now non-planning) parent.
- New PlanningLineageRecovery hosted service scans
  ~/.todo-app/planning-sessions/ and re-attaches a single, unambiguous
  blocked-by chain to its original planning parent when the
  parent_task_id links were lost. Refuses to guess when multiple
  candidate chains exist.

UI now exposes ConnectionRestoredEvent on IWorkerClient, fired on first
connect and every reconnect. ListsIslandViewModel refreshes counters
and TasksIslandViewModel reloads the current list - so stale counts no
longer survive a worker restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-05-18 16:28:57 +02:00
parent d094a21e09
commit 0d55002e5e
13 changed files with 270 additions and 44 deletions

View File

@@ -12,6 +12,8 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string, string, DateTime>? TaskStartedEvent;
event Action<string, string, string, DateTime>? TaskFinishedEvent;
event Action<string>? TaskUpdatedEvent;
/// <summary>Raised once when the SignalR connection is first established, and again on every reconnect.</summary>
event Action? ConnectionRestoredEvent;
event Action<string>? WorktreeUpdatedEvent;
event Action<string, string>? TaskMessageEvent;

View File

@@ -46,6 +46,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
public event Action<string, string>? TaskMessageEvent;
public event Action<string>? TaskUpdatedEvent;
public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? RunNowRequestedEvent;
public event Action<string>? ListUpdatedEvent;
@@ -76,6 +77,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
{
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
await SeedActiveTasksAsync();
Dispatcher.UIThread.Post(() => ConnectionRestoredEvent?.Invoke());
};
_hub.Reconnecting += _ =>
@@ -200,6 +202,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.StartAsync(ct);
Dispatcher.UIThread.Post(() => { IsConnected = true; IsReconnecting = false; });
await SeedActiveTasksAsync();
Dispatcher.UIThread.Post(() => ConnectionRestoredEvent?.Invoke());
return;
}
catch (OperationCanceledException)

View File

@@ -78,6 +78,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
_worker.TaskStartedEvent += (_slot, _id, _at) => _ = RefreshCountsAsync();
_worker.TaskFinishedEvent += (_slot, _id, _status, _at) => _ = RefreshCountsAsync();
_worker.TaskUpdatedEvent += _id => _ = RefreshCountsAsync();
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
}
}

View File

@@ -55,9 +55,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_worker = worker;
if (_worker is not null)
{
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.TaskMessageEvent += OnWorkerTaskMessage;
_worker.TaskUpdatedEvent += OnWorkerTaskUpdated;
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.TaskMessageEvent += OnWorkerTaskMessage;
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
_ = RefreshAllTagsAsync();
}
}