fix(worker): start_list_handler ehrlich machen, wenn keine App verbunden ist

ConnectedClientTracker zaehlt verbundene UI-Clients (WorkerHub.OnConnectedAsync/
OnDisconnectedAsync). StartListHandler prueft ihn vor dem Broadcast und liefert
bei 0 verbundenen Clients Requested=false plus Reason statt unbedingtem true.
Tool-Beschreibung und external-mcp.md an das reale Verhalten angepasst.
This commit is contained in:
mika kuns
2026-09-10 13:33:30 +02:00
parent 0235b43bad
commit 451c01848c
7 changed files with 177 additions and 8 deletions
+1 -1
View File
@@ -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
View File
@@ -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);
}
+18 -1
View File
@@ -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).
+1
View File
@@ -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>();