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.
100 lines
4.4 KiB
C#
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());
|
|
}
|
|
}
|