Files
ClaudeDo/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs
T
mika kuns 106c964410 feat(worker): surface task numbers in MCP tool return payloads
Adds Number alongside every task id in External/'s DTOs -- the two
central mappers (ToDto/ToRefDto -> TaskDto/TaskRefDto) plus every
DTO that carries a bare task id and bypasses them (batch results,
queue state, wait-for-change, config, attachments, handoff,
lifecycle, merge-preview-set, worktree list). Input resolution
(#123 as an argument) stays for slice 3.
2026-08-11 11:59:51 +02:00

100 lines
4.4 KiB
C#

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<QueueSlotDto> ActiveSlots,
IReadOnlyList<string> WaitingTaskIds,
IReadOnlyList<QueueWaitReasonDto> ScopeBlockedTasks,
IReadOnlyList<int> WaitingTaskNumbers);
[McpServerToolType]
public sealed class QueueStateMcpTools
{
private readonly QueueService _queue;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public QueueStateMcpTools(QueueService queue, IDbContextFactory<ClaudeDoDbContext> 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<GetQueueStateResult> 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<QueueWaitReasonDto>();
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());
}
}