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
@@ -312,7 +312,7 @@ public sealed class ConfigMcpToolsTests : IDisposable
Model = "sonnet",
SessionSkills = "[\"superpowers\"]",
VerifyCommand = "dotnet build",
SerializeOnFileOverlap = true,
PermissionMode = "acceptEdits",
});
await _sut.SetListConfig(listId, model: "opus", cancellationToken: CancellationToken.None);
@@ -324,7 +324,7 @@ public sealed class ConfigMcpToolsTests : IDisposable
Assert.Equal("dotnet build", cfg.VerifyCommand);
// SetConfigAsync copies the entity verbatim, so a field this tool doesn't expose would
// reset to its default unless it is carried over explicitly.
Assert.True(cfg.SerializeOnFileOverlap);
Assert.Equal("acceptEdits", cfg.PermissionMode);
}
[Fact]
@@ -335,7 +335,7 @@ public sealed class ConfigMcpToolsTests : IDisposable
{
ListId = listId,
Model = "sonnet",
SerializeOnFileOverlap = true,
PermissionMode = "acceptEdits",
});
var result = await _sut.SetListConfig(
@@ -349,7 +349,7 @@ public sealed class ConfigMcpToolsTests : IDisposable
var cfg = await _lists.GetConfigAsync(listId);
Assert.NotNull(cfg);
Assert.Null(cfg!.Model);
Assert.True(cfg.SerializeOnFileOverlap);
Assert.Equal("acceptEdits", cfg.PermissionMode);
}
[Fact]
@@ -176,48 +176,4 @@ public sealed class QueueStateMcpToolsTests : IDisposable
Assert.Equal(new[] { first.Id, second.Id }, result.WaitingTaskIds);
}
[Fact]
public async Task GetQueueState_ScopeBlockedTasks_Empty_WhenNoListSerializes()
{
var listId = await SeedListAsync();
var (_, sut) = CreateSut();
var running = await SeedTaskAsync(listId, TaskStatus.Running);
running.ScopeGlobs = "src/Foo.cs";
await _ctx.SaveChangesAsync();
var queued = await SeedTaskAsync(listId, TaskStatus.Queued);
queued.ScopeGlobs = "src/Foo.cs";
await _ctx.SaveChangesAsync();
var result = await sut.GetQueueState(CancellationToken.None);
Assert.Contains(queued.Id, result.WaitingTaskIds);
Assert.Empty(result.ScopeBlockedTasks);
}
[Fact]
public async Task GetQueueState_ScopeBlockedTasks_ReportsReasonAndBlocker_WhenListSerializes()
{
var listId = await SeedListAsync();
await _listRepo.SetConfigAsync(new ListConfigEntity { ListId = listId, SerializeOnFileOverlap = true });
var (_, sut) = CreateSut();
var running = await SeedTaskAsync(listId, TaskStatus.Running);
running.ScopeGlobs = "src/Foo.cs";
await _ctx.SaveChangesAsync();
var queued = await SeedTaskAsync(listId, TaskStatus.Queued);
queued.ScopeGlobs = "src/Foo.cs";
await _ctx.SaveChangesAsync();
var result = await sut.GetQueueState(CancellationToken.None);
Assert.Contains(queued.Id, result.WaitingTaskIds);
var reason = Assert.Single(result.ScopeBlockedTasks);
Assert.Equal(queued.Id, reason.TaskId);
Assert.Equal(running.Id, reason.BlockedByTaskId);
Assert.Equal("scope_overlap", reason.Reason);
}
}
@@ -8,8 +8,8 @@ using Xunit;
namespace ClaudeDo.Worker.Tests.Hub;
/// UpdateListConfig's "all fields blank -> delete the row" branch used to delete unconditionally,
/// silently dropping SerializeOnFileOverlap. The DTO field is tri-state: the list-settings modal
/// sends an explicit true/false, every other caller sends null and must not clear the stored flag.
/// silently dropping fields the caller doesn't own. It may only delete when nothing else is stored;
/// the preserve-the-stored-value side is covered by ListConfigTicketProjectTests.
public sealed class ListConfigHubTests : IDisposable
{
private readonly DbFixture _db = new();
@@ -42,13 +42,10 @@ public sealed class ListConfigHubTests : IDisposable
return listId;
}
private async Task SeedConfigAsync(string listId, string? model = null, bool serializeOnFileOverlap = false)
private async Task SeedConfigAsync(string listId, string? model = null)
{
await using var ctx = _db.CreateContext();
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = model, SerializeOnFileOverlap = serializeOnFileOverlap,
});
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = listId, Model = model });
}
private async Task<ListConfigEntity?> GetConfigAsync(string listId)
@@ -68,59 +65,4 @@ public sealed class ListConfigHubTests : IDisposable
Assert.Null(await GetConfigAsync(listId));
}
[Fact]
public async Task UpdateListConfig_AllBlank_WithSerializeOnFileOverlap_KeepsFlag_RowSurvives()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await SeedConfigAsync(listId, model: "opus", serializeOnFileOverlap: true);
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null));
var config = await GetConfigAsync(listId);
Assert.NotNull(config);
Assert.True(config!.SerializeOnFileOverlap);
Assert.Null(config.Model);
}
[Fact]
public async Task UpdateListConfig_WithModel_UpsertsNormally_PreservesSerializeOnFileOverlap()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await SeedConfigAsync(listId, serializeOnFileOverlap: true);
await hub.UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null));
var config = await GetConfigAsync(listId);
Assert.NotNull(config);
Assert.Equal("opus", config!.Model);
Assert.True(config.SerializeOnFileOverlap);
}
[Fact]
public async Task UpdateListConfig_ExplicitFalse_ClearsFlagAndDeletesOtherwiseEmptyRow()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await SeedConfigAsync(listId, serializeOnFileOverlap: true);
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, SerializeOnFileOverlap: false));
Assert.Null(await GetConfigAsync(listId));
}
[Fact]
public async Task UpdateListConfig_ExplicitTrue_SetsFlagOnOtherwiseEmptyRow()
{
var hub = CreateHub();
var listId = await SeedListAsync();
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, SerializeOnFileOverlap: true));
var config = await GetConfigAsync(listId);
Assert.NotNull(config);
Assert.True(config!.SerializeOnFileOverlap);
}
}
@@ -50,7 +50,6 @@ public sealed class QueuePickerTests : IDisposable
bool taskAgentTag = false,
int? sortOrder = null,
bool isManual = false,
string? scopeGlobs = null,
string? dependsOn = null)
{
var task = new TaskEntity
@@ -65,7 +64,6 @@ public sealed class QueuePickerTests : IDisposable
DependsOnTaskId = dependsOn,
CommitType = "feat",
IsManual = isManual,
ScopeGlobs = scopeGlobs,
};
await _tasks.AddAsync(task);
if (sortOrder is not null)
@@ -76,25 +74,6 @@ public sealed class QueuePickerTests : IDisposable
return task;
}
private async Task SetSerializeOnFileOverlapAsync(string listId, bool value)
=> await _lists.SetConfigAsync(new ListConfigEntity { ListId = listId, SerializeOnFileOverlap = value });
private async Task SeedWorktreeAsync(string taskId, WorktreeState state, string? diffStat)
{
_ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId,
Path = $"C:\\fake\\{taskId}",
BranchName = $"claudedo/{taskId[..8]}",
BaseCommit = "base",
HeadCommit = "head",
DiffStat = diffStat,
State = state,
CreatedAt = DateTime.UtcNow,
});
await _ctx.SaveChangesAsync();
}
[Fact]
public async Task ClaimNextAsync_Skips_ManualTasks()
{
@@ -260,86 +239,4 @@ public sealed class QueuePickerTests : IDisposable
var nonNull = results.Where(r => r is not null).ToList();
Assert.Single(nonNull);
}
[Fact]
public async Task ClaimNextAsync_SerializeOff_ClaimsOverlappingQueuedTask_EvenWithRunningOverlap()
{
// Default (option off): behavior is unaffected by ScopeGlobs, even when it overlaps a
// running sibling's declared scope.
var listId = await CreateListAsync();
await SeedAsync(listId, status: TaskStatus.Running, scopeGlobs: "src/Foo.cs");
var queued = await SeedAsync(listId, scopeGlobs: "src/Foo.cs");
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(queued.Id, picked!.Id);
}
[Fact]
public async Task ClaimNextAsync_SerializeOn_SkipsQueuedTask_OverlappingRunningSibling()
{
var listId = await CreateListAsync();
await SetSerializeOnFileOverlapAsync(listId, true);
await SeedAsync(listId, status: TaskStatus.Running, scopeGlobs: "src/Foo.cs");
var overlapping = await SeedAsync(listId, scopeGlobs: "src/Foo.cs", sortOrder: 0, createdAt: DateTime.UtcNow.AddMinutes(-5));
var clear = await SeedAsync(listId, scopeGlobs: "src/Bar.cs", sortOrder: 1, createdAt: DateTime.UtcNow);
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(clear.Id, picked!.Id);
var stillQueued = await _tasks.GetByIdAsync(overlapping.Id);
Assert.Equal(TaskStatus.Queued, stillQueued!.Status);
}
[Fact]
public async Task ClaimNextAsync_SerializeOn_NoDeclaredScope_StillClaimed()
{
var listId = await CreateListAsync();
await SetSerializeOnFileOverlapAsync(listId, true);
await SeedAsync(listId, status: TaskStatus.Running, scopeGlobs: "src/Foo.cs");
var queued = await SeedAsync(listId); // no ScopeGlobs declared -- no basis to hold it back
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(queued.Id, picked!.Id);
}
[Fact]
public async Task ClaimNextAsync_SerializeOn_SkipsQueuedTask_OverlappingUnmergedFinishedSiblingDiff()
{
var listId = await CreateListAsync();
await SetSerializeOnFileOverlapAsync(listId, true);
var finished = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedWorktreeAsync(finished.Id, WorktreeState.Active, "src/Foo.cs | 3 ++-");
await SeedAsync(listId, scopeGlobs: "src/Foo.cs");
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.Null(picked);
}
[Fact]
public async Task ClaimNextAsync_SerializeOn_IgnoresMergedSiblingDiff()
{
// A merged sibling's changes are already on the base branch -- not a live conflict risk,
// so it must not hold back an overlapping queued task.
var listId = await CreateListAsync();
await SetSerializeOnFileOverlapAsync(listId, true);
var merged = await SeedAsync(listId, status: TaskStatus.Done);
await SeedWorktreeAsync(merged.Id, WorktreeState.Merged, "src/Foo.cs | 3 ++-");
var queued = await SeedAsync(listId, scopeGlobs: "src/Foo.cs");
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(queued.Id, picked!.Id);
}
}
@@ -9,10 +9,9 @@ using Xunit;
namespace ClaudeDo.Worker.Tests.Tickets;
/// TicketProjectId shares the exact SetConfigAsync-copies-verbatim trap that SerializeOnFileOverlap
/// already hit once: any writer that doesn't carry the field forward silently clears the
/// list<->ticket-project link. UpdateListConfigDto's TicketProjectId is tri-state for the same
/// reason SerializeOnFileOverlap is: null = keep stored, <=0 = clear, >0 = set. The MCP
/// TicketProjectId sits in the SetConfigAsync-copies-verbatim trap: any writer that doesn't carry
/// the field forward silently clears the list&lt;-&gt;ticket-project link. UpdateListConfigDto's
/// TicketProjectId is therefore tri-state: null = keep stored, &lt;=0 = clear, &gt;0 = set. The MCP
/// set_list_config tool doesn't expose the field at all (UI-only), so it must preserve whatever is
/// already stored.
public sealed class ListConfigTicketProjectTests : IDisposable