fix(claude-do): merge fix(worker): start_list_handler ehrlich machen, wenn keine A
ClaudeDo-Task: 33e0f8f9-abac-4867-a873-ddc1c136a587
This commit is contained in:
@@ -98,6 +98,7 @@ Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
|
||||
| `TaskWaitMcpTools` | `WaitForTaskChange` |
|
||||
| `QueueStateMcpTools` | `GetQueueState` |
|
||||
| `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` |
|
||||
| `ListHandlerMcpTools` | `StartListHandler` |
|
||||
|
||||
## Per-tool behaviour worth knowing
|
||||
|
||||
@@ -235,6 +236,13 @@ task already outside those two (unknown ids reported as status `"NotFound"`, als
|
||||
**`AttachmentMcpTools`** — re-attaching the same `fileName` overwrites. Add/remove refuse on a
|
||||
`Running` task.
|
||||
|
||||
**`StartListHandler`** — the worker only broadcasts `ListHandlerStartRequested`; the connected
|
||||
ClaudeDo App does the actual work (creates the handler task, opens the triage ConPTY tile). Before
|
||||
broadcasting it checks `ConnectedClientTracker` (a `WorkerHub.OnConnectedAsync`/`OnDisconnectedAsync`-driven
|
||||
singleton count) — with zero connected clients the broadcast would reach nobody, so the tool
|
||||
returns `Requested: false` plus a `Reason` instead of claiming success. Does not queue or retry the
|
||||
request for a later connect.
|
||||
|
||||
**`GetDailyPrepCandidates`** — Idle, non-blocked tasks in a git repo **not** excluded by
|
||||
`AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks
|
||||
and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter`
|
||||
|
||||
@@ -30,7 +30,7 @@ Worker/
|
||||
Findings/ — FindingsStore + FindingsStoreLocator: the per-project `.claudedo/` trap store
|
||||
Git/ — GitExcludeWriter (`.git/info/exclude`) + GitHead, shared by skill seeding and findings
|
||||
Config/ — WorkerConfig
|
||||
Hub/ — WorkerHub, HubBroadcaster
|
||||
Hub/ — WorkerHub, HubBroadcaster, ConnectedClientTracker (connected-UI-client count)
|
||||
Logging/ — LogRingBuffer (30-min window) + BroadcastLogSink (Serilog → footer + overlay)
|
||||
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService
|
||||
Prime/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts,
|
||||
|
||||
+17
-6
@@ -7,32 +7,36 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record StartListHandlerResult(bool Requested, string ListId, string ListName, int TaskCount, IReadOnlyList<int> TaskNumbers);
|
||||
public sealed record StartListHandlerResult(bool Requested, string ListId, string ListName, int TaskCount, IReadOnlyList<int> TaskNumbers, string? Reason = null);
|
||||
|
||||
// The "Let Claude handle it" button, reachable from outside the UI. The worker only broadcasts;
|
||||
// the App owns the ConPTY tile and creates the handler task exactly as the button does
|
||||
// (MissionControlViewModel.OpenMergeHelperConPtySessionAsync), so there is one code path for
|
||||
// starting a run, not two. Without a connected App nothing happens -- the result says so.
|
||||
// starting a run, not two. Without a connected App the broadcast has nobody to receive it, so
|
||||
// ConnectedClientTracker is checked first and Requested comes back false with a Reason instead of
|
||||
// an unconditional true.
|
||||
[McpServerToolType]
|
||||
public sealed class ListHandlerMcpTools
|
||||
{
|
||||
private readonly ListRepository _lists;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly ConnectedClientTracker _clients;
|
||||
|
||||
public ListHandlerMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
|
||||
public ListHandlerMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster, ConnectedClientTracker clients)
|
||||
{
|
||||
_lists = lists;
|
||||
_tasks = tasks;
|
||||
_broadcaster = broadcaster;
|
||||
_clients = clients;
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Start a list-handler run (\"Let Claude handle it\") for a list, exactly like the button in " +
|
||||
"Mission Control: the connected ClaudeDo App creates the handler task and opens the triage " +
|
||||
"ConPTY tile for the given tasks. taskIds defaults to every Idle, non-manual task of the list. " +
|
||||
"Requires the App to be running and connected to this worker -- the worker only broadcasts " +
|
||||
"the request. taskIds accept task numbers (#123 or bare 123).")]
|
||||
"Requires the App to be running and connected to this worker -- if it isn't, Requested comes " +
|
||||
"back false with a reason and nothing is started. taskIds accept task numbers (#123 or bare 123).")]
|
||||
public async Task<StartListHandlerResult> StartListHandler(
|
||||
[Description("The list to run the handler on.")] string listId,
|
||||
[Description("Tasks to hand to the run, in the order to triage them. Omit for all Idle, non-manual tasks of the list.")] IReadOnlyList<string>? taskIds = null,
|
||||
@@ -65,8 +69,15 @@ public sealed class ListHandlerMcpTools
|
||||
if (chosen.Count == 0)
|
||||
throw new InvalidOperationException($"No tasks to hand to the list handler for {list.Name}.");
|
||||
|
||||
var numbers = chosen.Select(t => t.Number).ToList();
|
||||
if (!_clients.AnyConnected)
|
||||
{
|
||||
return new StartListHandlerResult(false, listId, list.Name, chosen.Count, numbers,
|
||||
"No ClaudeDo App is connected to this worker, so the request could not be delivered. Open the app and retry.");
|
||||
}
|
||||
|
||||
var ids = chosen.Select(t => t.Id).ToList();
|
||||
await _broadcaster.ListHandlerStartRequested(listId, ids);
|
||||
return new StartListHandlerResult(true, listId, list.Name, ids.Count, chosen.Select(t => t.Number).ToList());
|
||||
return new StartListHandlerResult(true, listId, list.Name, ids.Count, numbers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ClaudeDo.Worker.Hub;
|
||||
|
||||
/// <summary>
|
||||
/// Counts SignalR clients (the ClaudeDo App) currently connected to <see cref="WorkerHub"/>.
|
||||
/// A <c>Hub</c> instance is transient per connection, so this lives as a DI singleton and is
|
||||
/// updated from <see cref="WorkerHub.OnConnectedAsync"/>/<see cref="WorkerHub.OnDisconnectedAsync"/>
|
||||
/// — the only way an out-of-band caller (external MCP's start_list_handler, which only
|
||||
/// broadcasts) can tell whether anything is listening before claiming it did something.
|
||||
/// </summary>
|
||||
public sealed class ConnectedClientTracker
|
||||
{
|
||||
private int _count;
|
||||
|
||||
public bool AnyConnected => Volatile.Read(ref _count) > 0;
|
||||
|
||||
public void Increment() => Interlocked.Increment(ref _count);
|
||||
|
||||
public void Decrement() => Interlocked.Decrement(ref _count);
|
||||
}
|
||||
@@ -74,6 +74,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly TicketImportService _ticketImport;
|
||||
private readonly HandlerRestartCoordinator? _handlerRestart;
|
||||
private readonly HandlerRoundState? _roundState;
|
||||
private readonly ConnectedClientTracker? _clientTracker;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -114,7 +115,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
TicketClientFactory ticketClients = null!,
|
||||
TicketImportService ticketImport = null!,
|
||||
HandlerRestartCoordinator? handlerRestart = null,
|
||||
HandlerRoundState? roundState = null)
|
||||
HandlerRoundState? roundState = null,
|
||||
ConnectedClientTracker? clientTracker = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -155,6 +157,21 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_ticketImport = ticketImport;
|
||||
_handlerRestart = handlerRestart;
|
||||
_roundState = roundState;
|
||||
_clientTracker = clientTracker;
|
||||
}
|
||||
|
||||
// Tracks connected UI clients so an out-of-band caller (external MCP's start_list_handler)
|
||||
// can tell whether the broadcast it just sent had anyone to receive it.
|
||||
public override Task OnConnectedAsync()
|
||||
{
|
||||
_clientTracker?.Increment();
|
||||
return base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
_clientTracker?.Decrement();
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
||||
|
||||
@@ -99,6 +99,7 @@ builder.Services.AddSignalR(options => options.MaximumParallelInvocationsPerClie
|
||||
// Runner stack.
|
||||
builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
|
||||
builder.Services.AddSingleton<HubBroadcaster>();
|
||||
builder.Services.AddSingleton<ConnectedClientTracker>();
|
||||
builder.Services.AddSingleton<GitService>();
|
||||
builder.Services.AddSingleton<WorktreeManager>();
|
||||
builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class ListHandlerMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly CapturingHubContext _hubContext = new();
|
||||
|
||||
public ListHandlerMcpToolsTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private ListHandlerMcpTools BuildSut(ConnectedClientTracker tracker) =>
|
||||
new(_lists, _tasks, new HubBroadcaster(_hubContext), tracker);
|
||||
|
||||
private async Task<string> SeedListAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
return listId;
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle, string title = "t")
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), ListId = listId, Title = title,
|
||||
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartListHandler_NoConnectedClient_DoesNotBroadcastAndReportsNotRequested()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
await SeedTaskAsync(listId);
|
||||
var tracker = new ConnectedClientTracker();
|
||||
|
||||
var result = await BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Requested);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Reason));
|
||||
Assert.Equal(1, result.TaskCount);
|
||||
Assert.Empty(_hubContext.Proxy.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartListHandler_ConnectedClient_BroadcastsAndReportsRequested()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
var tracker = new ConnectedClientTracker();
|
||||
tracker.Increment();
|
||||
|
||||
var result = await BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.True(result.Requested);
|
||||
Assert.Null(result.Reason);
|
||||
Assert.Equal(1, result.TaskCount);
|
||||
Assert.Equal(new[] { task.Number }, result.TaskNumbers);
|
||||
|
||||
var call = Assert.Single(_hubContext.Proxy.Calls);
|
||||
Assert.Equal("ListHandlerStartRequested", call.Method);
|
||||
Assert.Equal(listId, call.Args[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartListHandler_LastClientDisconnects_StopsReportingRequested()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
await SeedTaskAsync(listId);
|
||||
var tracker = new ConnectedClientTracker();
|
||||
tracker.Increment();
|
||||
tracker.Decrement();
|
||||
|
||||
var result = await BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Requested);
|
||||
Assert.Empty(_hubContext.Proxy.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartListHandler_NoTasksToHandle_ThrowsRegardlessOfConnection()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var tracker = new ConnectedClientTracker();
|
||||
tracker.Increment();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user