Merge branch 'worktree-phase1-reaktivitaet'

This commit is contained in:
mika kuns
2026-08-07 11:01:54 +02:00
13 changed files with 575 additions and 61 deletions
@@ -25,6 +25,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// pick the flag up (see SyncInteractiveSessions).
private readonly HashSet<string> _interactiveSessionIds = new();
private static readonly TaskListFilterRegistry _filters = new();
// Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for
// one task can be in flight at once. Only the newest may write to the row.
private readonly Dictionary<string, long> _deltaSeq = new();
private long _deltaCounter;
public event EventHandler? SelectionChanged;
public event EventHandler? FocusAddTaskRequested;
@@ -161,6 +165,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
private async void OnWorkerTaskUpdated(string taskId)
=> await RefreshTaskFromWorkerAsync(taskId);
// Awaitable so tests can drive it deterministically. One retry, then a full reload:
// a swallowed exception here used to leave the row on a stale status permanently.
internal async Task RefreshTaskFromWorkerAsync(string taskId)
{
var list = _currentList;
if (list is null) return;
@@ -174,54 +183,79 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
return;
}
var seq = ++_deltaCounter;
_deltaSeq[taskId] = seq;
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == taskId);
// A parent transition (finalize/discard) broadcasts only the parent's id, but it
// changes its children's derived state — finalize flips them Draft→Planned, discard
// deletes them. The delta path below only touches the parent row and never recomputes
// the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted
// children, so reconcile the whole list when the updated task is (or owns) a subtree.
if (entity is not null &&
(entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id)))
{
LoadForList(list);
return;
}
var existing = Items.FirstOrDefault(r => r.Id == taskId);
if (entity is null)
{
if (existing is not null) Items.Remove(existing);
}
else
{
var matches = TaskMatchesList(entity, list);
if (existing is not null && matches) existing.UpdateFromEntity(entity);
else if (existing is not null) Items.Remove(existing);
else if (matches) { LoadForList(list); return; }
else return;
}
// Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips.
if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId))
{
var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId);
if (parent is not null)
parent.HasQueuedSubtasks = Items.Any(r =>
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
}
Regroup();
UpdateSubtitle();
await ApplyDeltaAsync(taskId, list, seq);
}
catch { }
catch (Exception first)
{
System.Diagnostics.Debug.WriteLine(
$"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying");
try
{
await ApplyDeltaAsync(taskId, list, seq);
}
catch (Exception second)
{
System.Diagnostics.Debug.WriteLine(
$"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload");
LoadForList(list);
}
}
}
private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == taskId);
// A newer refresh for this task started while we were reading — its result is fresher.
if (_deltaSeq.TryGetValue(taskId, out var current) && current != seq) return;
// A parent transition (finalize/discard) broadcasts only the parent's id, but it
// changes its children's derived state — finalize flips them Draft→Planned, discard
// deletes them. The delta path below only touches the parent row and never recomputes
// the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted
// children, so reconcile the whole list when the updated task is (or owns) a subtree.
if (entity is not null &&
(entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id)))
{
LoadForList(list);
return;
}
var existing = Items.FirstOrDefault(r => r.Id == taskId);
if (entity is null)
{
if (existing is not null) Items.Remove(existing);
}
else
{
var matches = TaskMatchesList(entity, list);
if (existing is not null && matches) existing.UpdateFromEntity(entity);
else if (existing is not null) Items.Remove(existing);
else if (matches) { LoadForList(list); return; }
else return;
}
// Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips.
if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId))
{
var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId);
if (parent is not null)
parent.HasQueuedSubtasks = Items.Any(r =>
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
}
Regroup();
UpdateSubtitle();
}
// NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning
-1
View File
@@ -159,7 +159,6 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag
- `TaskMessage`
- `WorktreeUpdated`
- `TaskUpdated`
- `RunCreated`
- `ListUpdated`
- `WorkerLog`
- `PrimeFired`
@@ -40,9 +40,6 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task ListUpdated(string listId) =>
_hub.Clients.All.SendAsync("ListUpdated", listId);
public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
_hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
public Task UsageUpdated(UsageSnapshotDto snapshot) =>
_hub.Clients.All.SendAsync("UsageUpdated", snapshot);
@@ -1,6 +1,7 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Online.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
@@ -15,19 +16,22 @@ public sealed class OnlineSyncService : BackgroundService
private readonly IOnlineAuthProvider _auth;
private readonly OnlineInboxConfig _config;
private readonly ILogger<OnlineSyncService> _logger;
private readonly HubBroadcaster _broadcaster;
public OnlineSyncService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IOnlineInboxApi api,
IOnlineAuthProvider auth,
OnlineInboxConfig config,
ILogger<OnlineSyncService> logger)
ILogger<OnlineSyncService> logger,
HubBroadcaster broadcaster)
{
_dbFactory = dbFactory;
_api = api;
_auth = auth;
_config = config;
_logger = logger;
_broadcaster = broadcaster;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -129,6 +133,8 @@ public sealed class OnlineSyncService : BackgroundService
CommitType = CommitTypeRegistry.DefaultType,
};
await tasks.AddAsync(entity, ct);
// Without this the imported task only shows up after a manual reload.
await _broadcaster.TaskUpdated(entity.Id);
await _api.MarkImportedAsync(remote.Id, ct);
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
+19
View File
@@ -346,9 +346,28 @@ public sealed class QueueService : BackgroundService
await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true);
}
catch (OperationCanceledException)
{
// Cancellation is driven by the cancel path, which already wrote the terminal status.
// Marking the task Failed here would be a regression (it would stomp Cancelled).
_logger.LogInformation("Slot runner cancelled for task {TaskId}", taskId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
// The picker already committed status='running' before this ran. Without this the
// task stays Running forever and the UI never hears about it — it keeps showing the
// pre-claim status because the raw-SQL claim itself never broadcasts.
try
{
await _state.FailAsync(taskId, DateTime.UtcNow,
$"Slot runner error: {ex.Message}", CancellationToken.None);
}
catch (Exception failEx)
{
_logger.LogError(failEx, "Could not mark task {TaskId} as failed after a slot error", taskId);
}
}
}
}
+3 -2
View File
@@ -311,6 +311,9 @@ public sealed class TaskRunner
{
var wtCtx = await _wtManager.CreateAsync(task, list, ct);
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
// The worktrees row was just inserted; without this the UI keeps showing the task
// as having no worktree until some unrelated event happens to refresh it.
await _broadcaster.WorktreeUpdated(task.Id);
return new RunDirResult(wtCtx.WorktreePath, wtCtx, null);
}
catch (Exception ex)
@@ -355,8 +358,6 @@ public sealed class TaskRunner
await taskRepo.SetLogPathAsync(taskId, logPath, ct);
}
await _broadcaster.RunCreated(taskId, runNumber, isRetry);
var arguments = _argsBuilder.Build(config);
await using var logWriter = new LogWriter(logPath);