feat(worker): add wait_for_task_change MCP tool

Replaces the list handler's Start-Sleep + blind get_task poll (Phase 3
of the merge-helper prompt) with a blocking MCP tool that returns as
soon as a task leaves Queued/Running, or times out. Implemented as an
async DB poll (short-lived DbContext, 500ms delay, no held connection)
rather than hooking HubBroadcaster, keeping the existing broadcast
callers untouched. timeoutSeconds is clamped server-side to 170s, under
the list handler's 200s MCP_TOOL_TIMEOUT.
This commit is contained in:
mika kuns
2026-08-05 10:49:55 +02:00
parent 334cf1e1d2
commit 194ce58a72
5 changed files with 216 additions and 1 deletions
+1 -1
View File
@@ -270,7 +270,7 @@ public static class PromptFiles
- Running or WaitingForChildren leave it; only poll.
- WaitingForReview leave it; it goes straight to Phase 4.
Poll get_task until every task has left Queued and Running WaitingForReview on success, Failed on error. Report progress as tasks land; do not poll silently for minutes.
Call wait_for_task_change with the ids of every task still Queued or Running (timeoutSeconds up to 170) instead of sleeping and polling get_task yourself. It returns as soon as any of them leaves Queued/Running WaitingForReview on success, Failed on error or reports timedOut if none did. Report progress as tasks land, then call it again with whatever ids are still Queued/Running until none remain.
## Phase 4 Review and merge
One task at a time, in the order the brief lists them.
+1
View File
@@ -42,6 +42,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
- `AgentMcpTools``ListAgents`
- `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only; includes `MaxParallelExecutions`)
- `TaskWaitMcpTools``WaitForTaskChange(taskIds, timeoutSeconds = 60)`: blocks until any given task leaves `Queued`/`Running`, or times out; returns immediately for a task already outside `Queued`/`Running` (unknown ids reported as status `"NotFound"`, also immediate). Implemented as an async DB poll (short-lived `DbContext` per check, 500ms delay between checks, no held connection or busy loop) rather than hooking `HubBroadcaster` — kept deliberately isolated so it can't regress the existing broadcast callers. `timeoutSeconds` is clamped server-side to `TaskWaitMcpTools.MaxTimeoutSeconds` (170s), comfortably under the list handler's `MCP_TOOL_TIMEOUT` (200s, see `InteractiveLaunchSpecService`) so the tool reports `timedOut: true` instead of racing the client's own abort. Replaces the list handler's old "sleep + poll get_task in a loop" Phase 3 instruction (`PromptFiles.MergeHelperDefault`).
- `AttachmentMcpTools``AddTaskAttachment(taskId, fileName, textContent?|base64Content?)`, `ListTaskAttachments`, `RemoveTaskAttachment`. Re-attaching the same fileName overwrites; add/remove refuse on a Running task.
- `ExternalMcpService` also exposes two daily-prep tools:
- `GetDailyPrepCandidates` — returns 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` helper (same file).
+83
View File
@@ -0,0 +1,83 @@
using System.ComponentModel;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
public sealed record TaskStatusChangeDto(string TaskId, string Status);
public sealed record WaitForTaskChangeResult(IReadOnlyList<TaskStatusChangeDto> Changed, bool TimedOut);
[McpServerToolType]
public sealed class TaskWaitMcpTools
{
// InteractiveLaunchSpecService sets MCP_TOOL_TIMEOUT=200000ms for the list handler
// session; this cap leaves a ~30s margin so the tool itself reports TimedOut instead
// of racing the client's own abort.
internal const int MaxTimeoutSeconds = 170;
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500);
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public TaskWaitMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
[McpServerTool, Description(
"Blocks until at least one of the given tasks leaves Queued/Running, or until timeoutSeconds elapses " +
"(clamped server-side to 170s). Returns immediately if any task is already outside Queued/Running " +
"when called (an unknown id is reported as status \"NotFound\" and counts as changed). Use this instead " +
"of polling get_task in a loop. Result: { changed: [{ taskId, status }], timedOut }.")]
public async Task<WaitForTaskChangeResult> WaitForTaskChange(
string[] taskIds, int timeoutSeconds = 60, CancellationToken cancellationToken = default)
{
if (taskIds.Length == 0)
throw new ArgumentException("taskIds must not be empty.", nameof(taskIds));
var timeout = TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, MaxTimeoutSeconds));
using var timeoutCts = new CancellationTokenSource(timeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
try
{
while (true)
{
var changed = await CheckOnceAsync(taskIds, linked.Token);
if (changed.Count > 0)
return new WaitForTaskChangeResult(changed, TimedOut: false);
await Task.Delay(PollInterval, linked.Token);
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new WaitForTaskChangeResult(Array.Empty<TaskStatusChangeDto>(), TimedOut: true);
}
}
private async Task<IReadOnlyList<TaskStatusChangeDto>> CheckOnceAsync(string[] taskIds, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var rows = await ctx.Tasks
.AsNoTracking()
.Where(t => taskIds.Contains(t.Id))
.Select(t => new { t.Id, t.Status })
.ToListAsync(ct);
var byId = rows.ToDictionary(r => r.Id, r => r.Status);
var result = new List<TaskStatusChangeDto>();
foreach (var id in taskIds)
{
if (!byId.TryGetValue(id, out var status))
{
result.Add(new TaskStatusChangeDto(id, "NotFound"));
continue;
}
if (status != TaskStatus.Queued && status != TaskStatus.Running)
result.Add(new TaskStatusChangeDto(id, status.ToString()));
}
return result;
}
}
+2
View File
@@ -294,6 +294,7 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<AgentMcpTools>();
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AttachmentStore>());
externalBuilder.Services.AddScoped<TaskAttachmentRepository>();
externalBuilder.Services.AddScoped<AttachmentMcpTools>();
@@ -308,6 +309,7 @@ if (cfg.ExternalMcpPort > 0)
.WithTools<AgentMcpTools>()
.WithTools<LifecycleMcpTools>()
.WithTools<AppSettingsMcpTools>()
.WithTools<TaskWaitMcpTools>()
.WithTools<AttachmentMcpTools>();
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");