Merge branch 'worktree-phase1-reaktivitaet'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user