refactor: Datei-Scope-Serialisierung entfernen

ScopeGlobs auf Tasks und SerializeOnFileOverlap auf der Listen-Config waren
ungenutzt: der Scope wurde nie befuellt, also hat der Queue-Picker nie
serialisiert. ScopeOverlap, das Picker-Gate, die DTO-Felder, die UI-Option und
die Spalten fallen weg (Migration DropFileScopeSerialization).
This commit is contained in:
mika kuns
2026-08-27 16:43:14 +02:00
parent 4e55f9161c
commit 84219a9f88
25 changed files with 1051 additions and 482 deletions
+4 -5
View File
@@ -261,11 +261,10 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding.
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`,
`agent_path`, `max_turns`, `session_skills`, `permission_mode`; tasks override each individually
(resolution lives in `EffectiveRunConfigResolver`, so `TaskRunner` and
`get_effective_run_config` can't drift). `verify_command` and `serialize_on_file_overlap` are
list-only — no task-level override. `verify_command` is written via `set_list_config`'s
`verifyCommand` parameter (`ConfigMcpTools`) and the UI's list config editor;
`serialize_on_file_overlap`, `permission_mode`, and `ticket_project_id` are UI-only
(`set_list_config` preserves but does not expose them).
`get_effective_run_config` can't drift). `verify_command` is list-only — no task-level override —
and is written via `set_list_config`'s `verifyCommand` parameter (`ConfigMcpTools`) and the UI's
list config editor; `permission_mode` and `ticket_project_id` are UI-only (`set_list_config`
preserves but does not expose them).
## Tickets (Bandel ticket system)
+4 -6
View File
@@ -105,12 +105,11 @@ public sealed class ConfigMcpTools
// Fields this tool doesn't expose but that live on the same row. They must survive every
// write here — ListRepository.SetConfigAsync copies the entity verbatim, so anything left
// at its default would silently reset (a SerializeOnFileOverlap reset only shows up as
// tasks no longer serializing, long after this write; a TicketProjectId reset silently
// kills the list<->ticket-project link — the tool has no ticket parameter on purpose,
// that link is UI-only).
// at its default would silently reset (a TicketProjectId reset silently kills the
// list<->ticket-project link — the tool has no ticket parameter on purpose, that link is
// UI-only).
var hasUnrelatedSettings = existing is not null
&& (existing.SessionSkills is not null || existing.SerializeOnFileOverlap
&& (existing.SessionSkills is not null
|| existing.PermissionMode is not null || existing.TicketProjectId is not null);
ListConfigDto? config;
@@ -130,7 +129,6 @@ public sealed class ConfigMcpTools
{
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, MaxTurns = mt,
VerifyCommand = vc, SessionSkills = existing?.SessionSkills,
SerializeOnFileOverlap = existing?.SerializeOnFileOverlap ?? false,
PermissionMode = existing?.PermissionMode,
TicketProjectId = existing?.TicketProjectId,
}, cancellationToken);
+2 -33
View File
@@ -9,14 +9,11 @@ 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]
@@ -37,11 +34,7 @@ public sealed class QueueStateMcpTools
"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.")]
"lists queued, unblocked, non-manual, due tasks in the order the queue would pick them next.")]
public async Task<GetQueueStateResult> GetQueueState(CancellationToken cancellationToken = default)
{
var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken);
@@ -68,32 +61,8 @@ public sealed class QueueStateMcpTools
.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,
configured, effective, activeSlots, waiting.Select(t => t.Id).ToList(),
waiting.Select(t => t.Number).ToList());
}
}
+5 -11
View File
@@ -560,15 +560,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var verifyCommand = dto.VerifyCommand.NullIfBlank();
var permissionMode = NormalizePermissionMode(dto.PermissionMode);
// A null SerializeOnFileOverlap means "leave it as stored" — only the list-settings modal
// owns that field, so every other caller must not drop it (neither by deleting the row nor
// by overwriting it: SetConfigAsync copies the entity verbatim).
// Tri-State: null = gespeicherten Wert behalten, 0 = Verknüpfung löschen. Ohne das würde
// jeder fremde Writer (set_list_config MCP-Tool, Agent-Settings) die Ticket-Verknüpfung
// still kappen — SetConfigAsync kopiert verbatim.
var existing = await repo.GetConfigAsync(dto.ListId);
var serializeOnFileOverlap = dto.SerializeOnFileOverlap ?? existing?.SerializeOnFileOverlap ?? false;
// Gleiche Tri-State-Regel wie oben: null = gespeicherten Wert behalten, 0 = Verknüpfung
// löschen. Ohne das würde jeder fremde Writer (set_list_config MCP-Tool, Agent-Settings)
// die Ticket-Verknüpfung still kappen — SetConfigAsync kopiert verbatim.
var ticketProjectId = dto.TicketProjectId switch
{
null => existing?.TicketProjectId,
@@ -576,7 +571,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var id => id,
};
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && !serializeOnFileOverlap && ticketProjectId is null)
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null && verifyCommand is null && permissionMode is null && ticketProjectId is null)
{
await repo.DeleteConfigAsync(dto.ListId);
}
@@ -591,7 +586,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
MaxTurns = dto.MaxTurns,
SessionSkills = sessionSkills,
VerifyCommand = verifyCommand,
SerializeOnFileOverlap = serializeOnFileOverlap,
PermissionMode = permissionMode,
TicketProjectId = ticketProjectId,
});
@@ -617,7 +611,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId);
if (config is null) return null;
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.SerializeOnFileOverlap, config.PermissionMode, config.TicketProjectId);
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills), config.VerifyCommand, config.PermissionMode, config.TicketProjectId);
}
public async Task<SetTaskStatusResultDto> SetTaskStatus(string taskId, string status)
-73
View File
@@ -18,22 +18,6 @@ public sealed class QueuePicker : IQueuePicker
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
var startedAtStr = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fffffff");
var serializingListIds = await ctx.ListConfigs
.Where(c => c.SerializeOnFileOverlap)
.Select(c => c.ListId)
.ToListAsync(ct);
// Fast path: no list has opted into file-scope serialization, so behavior and cost stay
// identical to before that option existed -- single atomic UPDATE...RETURNING.
if (serializingListIds.Count == 0)
return await ClaimTopEligibleAsync(ctx, nowStr, startedAtStr, ct);
return await ClaimRespectingScopeAsync(ctx, now, startedAtStr, serializingListIds.ToHashSet(StringComparer.Ordinal), ct);
}
private static async Task<TaskEntity?> ClaimTopEligibleAsync(
ClaudeDoDbContext ctx, string nowStr, string startedAtStr, CancellationToken ct)
{
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
// Raw SQL because EF cannot express UPDATE...RETURNING.
// Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due
@@ -62,61 +46,4 @@ public sealed class QueuePicker : IQueuePicker
return rows.FirstOrDefault();
}
// At least one list wants overlapping-scope tasks serialized: walk eligible candidates in the
// usual order and skip any whose declared scope overlaps a running or awaiting-merge sibling
// in the same list. A candidate with no declared scope, or belonging to a list not in
// serializingListIds, is claimed exactly as before -- there is no attempt to predict a scope
// that was never declared and no finished sibling to infer it from.
private async Task<TaskEntity?> ClaimRespectingScopeAsync(
ClaudeDoDbContext ctx, DateTime now, string startedAtStr, HashSet<string> serializingListIds, CancellationToken ct)
{
var candidates = await ctx.Tasks
.AsNoTracking()
.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(ct);
// The same depends-on gate ClaimTopEligibleAsync applies in SQL: a declared dependency must
// be Done. Resolved as one extra query over just the referenced ids (rather than repeating
// the correlated subquery) so both claim paths agree -- without it, opting a list into
// scope serialization would silently stop enforcing dependencies.
var dependencyIds = candidates
.Where(t => t.DependsOnTaskId != null)
.Select(t => t.DependsOnTaskId!)
.Distinct()
.ToList();
var doneDependencyIds = dependencyIds.Count == 0
? new HashSet<string>(StringComparer.Ordinal)
: (await ctx.Tasks.AsNoTracking()
.Where(d => dependencyIds.Contains(d.Id) && d.Status == TaskStatus.Done)
.Select(d => d.Id)
.ToListAsync(ct))
.ToHashSet(StringComparer.Ordinal);
foreach (var candidate in candidates)
{
if (candidate.DependsOnTaskId is { } dependsOn && !doneDependencyIds.Contains(dependsOn))
continue;
if (serializingListIds.Contains(candidate.ListId)
&& await ScopeOverlap.FindBlockingSiblingAsync(ctx, candidate, ct) is not null)
continue;
var rows = await ctx.Tasks.FromSqlRaw("""
UPDATE tasks SET status = 'running', started_at = {1}
WHERE id = {0} AND status = 'queued'
RETURNING *
""", candidate.Id, startedAtStr).ToListAsync(ct);
var claimed = rows.FirstOrDefault();
if (claimed is not null) return claimed;
// Lost the race for this row to a concurrent picker -- try the next candidate.
}
return null;
}
}
-89
View File
@@ -1,89 +0,0 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Queue;
// Conservative overlap check between two sets of declared/observed file scopes. A scope entry may
// be an exact path or a glob (e.g. "src/Foo/*.cs"); comparing the literal prefix before the first
// wildcard is enough to catch real collisions without a full glob-matching engine, and it never
// under-reports -- worst case it serializes two tasks that would not actually have collided.
public static class ScopeOverlap
{
public static IReadOnlyList<string> ParseScopeGlobs(string? scopeGlobs)
=> string.IsNullOrWhiteSpace(scopeGlobs)
? []
: scopeGlobs.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
public static IReadOnlyList<string> ParseDiffStatFiles(string? diffStat)
{
if (string.IsNullOrWhiteSpace(diffStat)) return [];
var files = new List<string>();
foreach (var line in diffStat.Split('\n'))
{
var idx = line.IndexOf('|');
if (idx > 0) files.Add(line[..idx].Trim());
}
return files;
}
public static bool Overlaps(IEnumerable<string> a, IEnumerable<string> b)
{
var bPrefixes = b.Select(LiteralPrefix).Where(p => p.Length > 0).ToList();
if (bPrefixes.Count == 0) return false;
foreach (var pa in a.Select(LiteralPrefix))
{
if (pa.Length == 0) continue;
foreach (var pb in bPrefixes)
{
if (PrefixesOverlap(pa, pb)) return true;
}
}
return false;
}
private static bool PrefixesOverlap(string a, string b)
=> a.Length <= b.Length
? b.StartsWith(a, StringComparison.OrdinalIgnoreCase)
: a.StartsWith(b, StringComparison.OrdinalIgnoreCase);
private static string LiteralPrefix(string pattern)
{
var normalized = pattern.Trim().Replace('\\', '/').TrimStart('/');
var idx = normalized.IndexOfAny(['*', '?']);
return idx < 0 ? normalized : normalized[..idx];
}
// Finds the first currently-running or awaiting-merge sibling in the candidate's list whose
// scope overlaps the candidate's declared ScopeGlobs. Returns null (no basis to hold it back)
// when the candidate declares no scope at all -- this never predicts scope for a task that
// hasn't run and has none declared.
public static async Task<string?> FindBlockingSiblingAsync(ClaudeDoDbContext ctx, TaskEntity candidate, CancellationToken ct)
{
var scope = ParseScopeGlobs(candidate.ScopeGlobs);
if (scope.Count == 0) return null;
var siblings = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Where(t => t.ListId == candidate.ListId
&& t.Id != candidate.Id
&& (t.Status == TaskStatus.Running
|| (t.Status == TaskStatus.WaitingForReview && t.Worktree != null && t.Worktree.State == WorktreeState.Active)))
.ToListAsync(ct);
foreach (var sibling in siblings)
{
var siblingScope = sibling.Status == TaskStatus.Running
? ParseScopeGlobs(sibling.ScopeGlobs)
: ParseDiffStatFiles(sibling.Worktree?.DiffStat);
if (siblingScope.Count > 0 && Overlaps(scope, siblingScope))
return sibling.Id;
}
return null;
}
}