feat(mcp): update_task kann isManual setzen + List-Handler stellt seinen Task auf Review
Teil A: update_task bekommt einen optionalen isManual-Parameter (null = unveraendert); TaskDto/TaskRefDto spiegeln IsManual zurueck. Teil B: neuer submit_task_for_review MCP-Tool (LifecycleMcpTools) laesst einen Handler-Task selbst auf WaitingForReview gehen -- fuer einen worktree-losen Task wird HandlerHeadCommit vom aktuellen HEAD gestempelt, sonst werden offene Aenderungen committet. Die Submit-Logik ist aus WorkerHub.SubmitTaskForReview in InteractiveReviewSubmissionService extrahiert; der Hub ruft sie jetzt nur noch auf. Der Merge-Prompt-Default weist die Endzweige (merge ohne Rerun, merge_final) an, den eigenen Handler-Task nach der Summary einzureichen.
This commit is contained in:
@@ -198,7 +198,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var queue = CreateQueue();
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal("new title", dto.Title);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
@@ -219,13 +219,57 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
|
||||
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, null, CancellationToken.None);
|
||||
|
||||
Assert.Equal(task.Id, dto.Id);
|
||||
Assert.Equal(listId, dto.ListId);
|
||||
Assert.Equal("new title", dto.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_SetsIsManualTrue()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, isManual: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.True(dto.IsManual);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.True(loaded!.IsManual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_SetsIsManualFalse_ClearsExistingFlag()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
task.IsManual = true;
|
||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, isManual: false, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(dto.IsManual);
|
||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.False(loaded!.IsManual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTask_IsManualNull_LeavesUnchanged()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId);
|
||||
task.IsManual = true;
|
||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
||||
var sut = BuildSut(CreateQueue());
|
||||
|
||||
var dto = await sut.UpdateTask(task.Id, title: "new title", cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.True(dto.IsManual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_ReturnsTaskNumber()
|
||||
{
|
||||
@@ -443,7 +487,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
|
||||
sut.UpdateTask(task.Id, "x", null, null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -453,7 +497,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var sut = BuildSut(queue);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None));
|
||||
sut.UpdateTask("does-not-exist", "x", null, null, null, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+124
-6
@@ -2,13 +2,19 @@ using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -20,6 +26,9 @@ public sealed class LifecycleMcpToolsTests : IDisposable
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
private readonly List<GitRepoFixture> _repos = new();
|
||||
|
||||
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
|
||||
|
||||
public LifecycleMcpToolsTests()
|
||||
{
|
||||
@@ -28,7 +37,12 @@ public sealed class LifecycleMcpToolsTests : IDisposable
|
||||
_lists = new ListRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var r in _repos) r.Dispose();
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private LifecycleMcpTools BuildSut()
|
||||
{
|
||||
@@ -37,27 +51,65 @@ public sealed class LifecycleMcpToolsTests : IDisposable
|
||||
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
|
||||
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
|
||||
};
|
||||
var git = new GitService();
|
||||
var dbFactory = _db.CreateFactory();
|
||||
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
|
||||
return new LifecycleMcpTools(_tasks, reset);
|
||||
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
|
||||
var aggregator = new PlanningAggregator(dbFactory, git, NullLogger<PlanningAggregator>.Instance);
|
||||
var planningMerge = new PlanningMergeOrchestrator(
|
||||
dbFactory, merge, aggregator, broadcaster, git, state, NullLogger<PlanningMergeOrchestrator>.Instance);
|
||||
var svc = new ExternalMcpService(
|
||||
_tasks, _lists, CreateQueue(dbFactory, wtManager, state, broadcaster), broadcaster,
|
||||
state, git, dbFactory, maintenance, merge, planningMerge,
|
||||
new BaseDirtyChecker(git, NullLogger<BaseDirtyChecker>.Instance));
|
||||
var reviewSubmission = new InteractiveReviewSubmissionService(dbFactory, git, state, wtManager);
|
||||
return new LifecycleMcpTools(_tasks, reset, svc, reviewSubmission);
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(TaskStatus status)
|
||||
private QueueService CreateQueue(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory, WorktreeManager wtManager, ITaskStateService state, HubBroadcaster broadcaster)
|
||||
{
|
||||
var cfg = new WorkerConfig
|
||||
{
|
||||
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cdq_{Guid.NewGuid():N}"),
|
||||
LogRoot = Path.Combine(Path.GetTempPath(), $"cdql_{Guid.NewGuid():N}"),
|
||||
QueueBackstopIntervalMs = 50,
|
||||
};
|
||||
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, new ClaudeArgsBuilder(), cfg,
|
||||
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
|
||||
var runCancels = new RunCancellationRegistry(NullLogger<RunCancellationRegistry>.Instance);
|
||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, runCancels);
|
||||
return new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance,
|
||||
new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state, runCancels,
|
||||
new FakeUsageGate(), new UsageState(), broadcaster);
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(
|
||||
TaskStatus status, string? workingDir = null, string? handlerBaseCommit = null)
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = workingDir, CreatedAt = DateTime.UtcNow });
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
|
||||
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
||||
Status = status, HandlerBaseCommit = handlerBaseCommit,
|
||||
CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
private GitRepoFixture CreateRepo()
|
||||
{
|
||||
var f = new GitRepoFixture();
|
||||
_repos.Add(f);
|
||||
return f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetFailedTask_OnFailed_ResetsToIdle()
|
||||
{
|
||||
@@ -89,4 +141,70 @@ public sealed class LifecycleMcpToolsTests : IDisposable
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.ResetFailedTask("missing", CancellationToken.None));
|
||||
}
|
||||
|
||||
// ── SubmitTaskForReview ──
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitTaskForReview_WorktreeLessHandlerTask_StampsHeadCommit_TransitionsToWaitingForReview()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
var task = await SeedTaskAsync(TaskStatus.Idle, workingDir: repo.RepoDir, handlerBaseCommit: repo.BaseCommit);
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge");
|
||||
var expectedHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
var sut = BuildSut();
|
||||
|
||||
var dto = await sut.SubmitTaskForReview(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal("WaitingForReview", dto.Status);
|
||||
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
|
||||
Assert.Equal(expectedHead, reloaded.HandlerHeadCommit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitTaskForReview_NoWorktreeAndNoHandlerBaseCommit_Throws()
|
||||
{
|
||||
var task = await SeedTaskAsync(TaskStatus.Idle, workingDir: Path.GetTempPath());
|
||||
var sut = BuildSut();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.SubmitTaskForReview(task.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitTaskForReview_RunningTask_Throws()
|
||||
{
|
||||
var task = await SeedTaskAsync(TaskStatus.Running, workingDir: Path.GetTempPath(), handlerBaseCommit: "abc123");
|
||||
var sut = BuildSut();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.SubmitTaskForReview(task.Id, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitTaskForReview_AlreadyDone_Throws_HandlerHeadCommitUntouched()
|
||||
{
|
||||
var task = await SeedTaskAsync(TaskStatus.Done, workingDir: Path.GetTempPath(), handlerBaseCommit: "abc123");
|
||||
var sut = BuildSut();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.SubmitTaskForReview(task.Id, CancellationToken.None));
|
||||
Assert.Contains("Idle or Failed", ex.Message);
|
||||
|
||||
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
||||
Assert.Null(reloaded!.HandlerHeadCommit);
|
||||
Assert.Equal(TaskStatus.Done, reloaded.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitTaskForReview_NotFound_Throws()
|
||||
{
|
||||
var sut = BuildSut();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.SubmitTaskForReview("missing", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
@@ -65,13 +66,15 @@ public sealed class MergeHelperTaskHubTests : IDisposable
|
||||
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
|
||||
new WorkerConfig { ClaudeBin = "claude" });
|
||||
var built = TaskStateServiceBuilder.Build(factory);
|
||||
var reviewSubmission = new InteractiveReviewSubmissionService(factory, git, built.State, wtManager);
|
||||
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, null!, factory, null!, null!, null!,
|
||||
null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!,
|
||||
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(),
|
||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!,
|
||||
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git);
|
||||
logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git,
|
||||
interactiveReviewSubmission: reviewSubmission);
|
||||
hub.Clients = new FakeHubCallerClients(_proxy);
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return hub;
|
||||
|
||||
Reference in New Issue
Block a user