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:
@@ -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<->ticket-project link. UpdateListConfigDto's
|
||||
/// TicketProjectId is therefore tri-state: null = keep stored, <=0 = clear, >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
|
||||
|
||||
Reference in New Issue
Block a user