using System.ComponentModel; using ClaudeDo.Data; using ClaudeDo.Worker.Queue; using Microsoft.EntityFrameworkCore; using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.External; public sealed record QueueSlotDto(string Slot, string TaskId, int? Number, DateTime StartedAt); public sealed record QueueWaitReasonDto(string TaskId, int Number, string Reason, string BlockedByTaskId, int? BlockedByNumber); public sealed record GetQueueStateResult( int ConfiguredSlots, int EffectiveSlots, IReadOnlyList ActiveSlots, IReadOnlyList WaitingTaskIds, IReadOnlyList ScopeBlockedTasks, IReadOnlyList WaitingTaskNumbers); [McpServerToolType] public sealed class QueueStateMcpTools { private readonly QueueService _queue; private readonly IDbContextFactory _dbFactory; public QueueStateMcpTools(QueueService queue, IDbContextFactory dbFactory) { _queue = queue; _dbFactory = dbFactory; } [McpServerTool, Description( "Read-only snapshot of the execution queue -- call this to observe slot occupancy instead " + "of inferring it from maxParallelExecutions. effectiveSlots is configuredSlots stepped down " + "by the usage throttle (lower when the 5h/7d usage window fills up), so comparing the two " + "shows whether throttling is currently active. Each active slot is \"queue\" (a normal " + "queue slot) or \"override\" (the single run_task_now/continue_task slot). waitingTaskIds " + "lists queued, unblocked, non-manual, due tasks in the order the queue would pick them next -- " + "including any held back purely by file-scope overlap, which is why a waiting task can " + "outlast a free slot. scopeBlockedTasks explains those: the list opted into " + "serializeOnFileOverlap and this task's declared scope overlaps blockedByTaskId, a running " + "or awaiting-merge sibling in the same list.")] public async Task GetQueueState(CancellationToken cancellationToken = default) { var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken); var active = _queue.GetActive(); await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); var now = DateTime.UtcNow; var activeTaskIds = active.Select(a => a.taskId).ToList(); var activeNumbers = await ctx.Tasks .Where(t => activeTaskIds.Contains(t.Id)) .Select(t => new { t.Id, t.Number }) .ToDictionaryAsync(t => t.Id, t => t.Number, cancellationToken); var activeSlots = active .Select(a => new QueueSlotDto(a.slot, a.taskId, activeNumbers.TryGetValue(a.taskId, out var n) ? n : null, a.startedAt)) .ToList(); var waiting = await ctx.Tasks .Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId == null && !t.IsManual && (t.ScheduledFor == null || t.ScheduledFor <= now)) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(cancellationToken); var serializingListIds = (await ctx.ListConfigs .Where(c => c.SerializeOnFileOverlap) .Select(c => c.ListId) .ToListAsync(cancellationToken)) .ToHashSet(StringComparer.Ordinal); var scopeBlocked = new List(); if (serializingListIds.Count > 0) { foreach (var t in waiting) { if (!serializingListIds.Contains(t.ListId)) continue; var blockerId = await ScopeOverlap.FindBlockingSiblingAsync(ctx, t, cancellationToken); if (blockerId is not null) { var blockerNumber = await ctx.Tasks .Where(b => b.Id == blockerId) .Select(b => (int?)b.Number) .FirstOrDefaultAsync(cancellationToken); scopeBlocked.Add(new QueueWaitReasonDto(t.Id, t.Number, "scope_overlap", blockerId, blockerNumber)); } } } return new GetQueueStateResult( configured, effective, activeSlots, waiting.Select(t => t.Id).ToList(), scopeBlocked, waiting.Select(t => t.Number).ToList()); } }