using System.ComponentModel; using ClaudeDo.Data; using ClaudeDo.Data.Repositories; using Microsoft.EntityFrameworkCore; using ModelContextProtocol; using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.External; // BlockedReason is set only when Status is "Blocked" -- a Queued task the picker will not // claim yet, either because of a planning-chain predecessor or an unmet depends-on link. public sealed record TaskStatusChangeDto(string TaskId, string Status, string? BlockedReason = null, int? Number = null); public sealed record WaitForTaskChangeResult(IReadOnlyList Changed, bool TimedOut); [McpServerToolType] public sealed class TaskWaitMcpTools { // Every ClaudeDo-owned launcher (ClaudeProcess for headless runs, InteractiveLaunchSpecService // for ConPTY sessions) sets MCP_TOOL_TIMEOUT=930000ms on the claude CLI process; this cap // leaves a ~30s margin under that so the tool itself reports TimedOut instead of racing the // client's own wall-clock abort. A caller running claude with a different MCP_TOOL_TIMEOUT (or // none -- the CLI default is 60s) will see its own client-side timeout fire first; this tool // has no way to detect or compensate for that from the server side. // // MCP_TOOL_TIMEOUT is a separate mechanism from Claude Code's idle-silence abort // (CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT, default 300s for HTTP-transport MCP servers like this // one) -- no ClaudeDo launcher raises that one. Without periodic activity, a wait anywhere // near this cap gets killed at ~300s regardless of launcher. WaitForTaskChange survives that // by reporting MCP progress well under 300s apart (see ProgressReportInterval below), which // resets Claude Code's idle timer. internal const int MaxTimeoutSeconds = 900; private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500); // Not readonly: tests shrink this to observe a progress report without waiting 30s. internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30); private readonly IDbContextFactory _dbFactory; public TaskWaitMcpTools(IDbContextFactory dbFactory) { _dbFactory = dbFactory; } [McpServerTool, Description( "Blocks until at least one of the given tasks leaves Queued/Running -- use this instead of " + "polling get_task in a loop. Returns immediately if a task is already outside Queued/Running " + "(an unknown id reports status \"NotFound\" and counts as changed). A Queued task held by a " + "depends_on link whose target isn't Done reports immediately as status \"Blocked\" with " + "blockedReason set, instead of silently waiting out the full timeout -- that link never " + "resolves on its own. A planning-chain block does NOT report that way: it clears by itself " + "when the predecessor finishes, so the wait simply continues. Pitfall: a planning parent goes Running -> " + "WaitingForChildren while its children are still working, so by default waiting on a parent " + "returns early; see treatWaitingForChildrenAsBusy. Sends MCP progress " + "pings every 30s while waiting so a long wait survives the calling client's own idle-silence " + "abort (Claude Code defaults to killing an MCP call after ~300s of silence) -- this is not " + "guaranteed by every possible MCP client.")] public async Task WaitForTaskChange( string[] taskIds, [Description( "How long to wait, in seconds, before giving up. Clamped server-side to 900s (15 min) " + "regardless of what's passed.")] int timeoutSeconds = 60, [Description( "When true, WaitingForChildren still counts as busy, so waiting on a planning parent " + "continues until it reaches WaitingForReview or a terminal status instead of returning " + "as soon as it leaves Running.")] bool treatWaitingForChildrenAsBusy = false, IProgress? progress = null, CancellationToken cancellationToken = default) { if (taskIds.Length == 0) throw new ArgumentException("taskIds must not be empty.", nameof(taskIds)); taskIds = await ResolveIdsAsync(taskIds, cancellationToken); var timeout = TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, MaxTimeoutSeconds)); using var timeoutCts = new CancellationTokenSource(timeout); using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); try { var lastProgressAt = DateTime.UtcNow; while (true) { var changed = await CheckOnceAsync(taskIds, treatWaitingForChildrenAsBusy, linked.Token); if (changed.Count > 0) return new WaitForTaskChangeResult(changed, TimedOut: false); var now = DateTime.UtcNow; if (now - lastProgressAt >= ProgressReportInterval) { lastProgressAt = now; progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "Still waiting for a task status change." }); } await Task.Delay(PollInterval, linked.Token); } } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return new WaitForTaskChangeResult(Array.Empty(), TimedOut: true); } } private async Task ResolveIdsAsync(string[] ids, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var tasks = new TaskRepository(ctx); var resolved = new string[ids.Length]; for (var i = 0; i < ids.Length; i++) { try { resolved[i] = await TaskIdResolver.ResolveAsync(tasks, ids[i], ct); } catch (InvalidOperationException) { // A #/bare-number id with no matching task -- keep the original, // unresolvable id so CheckOnceAsync's lookup misses and reports "NotFound", // per this tool's documented contract, instead of failing the whole call. resolved[i] = ids[i]; } } return resolved; } private async Task> CheckOnceAsync( string[] taskIds, bool treatWaitingForChildrenAsBusy, 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.Number, t.Status, t.BlockedByTaskId, t.DependsOnTaskId }) .ToListAsync(ct); var byId = rows.ToDictionary(r => r.Id, r => r); // A Queued task with an unmet depends_on link never becomes "changed" by itself -- the // picker will not touch it. Batch-resolve those dependencies' statuses once instead of a // query per candidate. var dependencyIds = rows .Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null && r.DependsOnTaskId is not null) .Select(r => r.DependsOnTaskId!) .Distinct() .ToList(); var dependencyStatuses = dependencyIds.Count == 0 ? new Dictionary() : await ctx.Tasks.AsNoTracking() .Where(t => dependencyIds.Contains(t.Id)) .Select(t => new { t.Id, t.Status }) .ToDictionaryAsync(t => t.Id, t => t.Status, ct); var result = new List(); foreach (var id in taskIds) { if (!byId.TryGetValue(id, out var row)) { result.Add(new TaskStatusChangeDto(id, "NotFound")); continue; } // A planning-chain BlockedByTaskId is deliberately NOT reported as "Blocked": it // resolves on its own the moment the predecessor reaches any terminal state // (PlanningChainCoordinator), so it's exactly what a caller waiting on a queued // fan-out wants to keep waiting through. Reporting it would return instantly for // every chained child and turn the wait into a one-turn-per-poll busy loop. if (row.Status == TaskStatus.Queued && row.BlockedByTaskId is null) { if (row.DependsOnTaskId is not null) { var known = dependencyStatuses.TryGetValue(row.DependsOnTaskId, out var depStatus); if (!known || depStatus != TaskStatus.Done) { result.Add(new TaskStatusChangeDto(id, "Blocked", $"Blocked: depends on task {row.DependsOnTaskId} (status: " + (known ? depStatus.ToString() : "not found") + ").", row.Number)); continue; } } } var busy = row.Status == TaskStatus.Queued || row.Status == TaskStatus.Running || (treatWaitingForChildrenAsBusy && row.Status == TaskStatus.WaitingForChildren); if (!busy) result.Add(new TaskStatusChangeDto(id, row.Status.ToString(), Number: row.Number)); } return result; } }