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:
mika kuns
2026-08-21 16:51:18 +02:00
parent 36720d33ae
commit ea2271cc3e
9 changed files with 325 additions and 76 deletions
+3 -3
View File
@@ -530,9 +530,9 @@ public static class PromptFiles
## Handoff / end
- If you started any reruns: call handoff_list_handler(taskId, <the rerun task ids>, nextPhase: "wait_final"), then stop your turn immediately do not merge anything yet that you just restarted.
- If your own phase was "merge_final": never start a rerun, no matter how tempting just merge what you can, report the rest, and print the summary below.
- Otherwise (no reruns started, phase was "merge"): print the summary below and stop; there is nothing left to hand off.
- If you started any reruns: call handoff_list_handler(taskId, <the rerun task ids>, nextPhase: "wait_final"), then stop your turn immediately do not merge anything yet that you just restarted. Do NOT submit this run for review yet the chain isn't finished.
- If your own phase was "merge_final": never start a rerun, no matter how tempting just merge what you can, report the rest, print the summary below, then call submit_task_for_review(taskId) on this session's own handler task id so its diff becomes reviewable.
- Otherwise (no reruns started, phase was "merge"): print the summary below, then call submit_task_for_review(taskId) on this session's own handler task id there is nothing left to hand off.
## Phase 5 Summary
Print one line per task from the brief:
+16 -6
View File
@@ -61,7 +61,8 @@ public sealed record TaskDto(
// True only while Status is Queued and the picker will not claim this task yet -- either a
// planning-chain predecessor or DependsOnTaskId hasn't reached Done. See BlockedReason.
bool Blocked = false,
string? BlockedReason = null);
string? BlockedReason = null,
bool IsManual = false);
// Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed
// without re-sending Description/Result, which the caller just sent or already has.
@@ -83,7 +84,8 @@ public sealed record TaskRefDto(
string? DependsOnTaskId = null,
bool Blocked = false,
string? BlockedReason = null,
DirtyBaseWarning? BaseDirty = null);
DirtyBaseWarning? BaseDirty = null,
bool IsManual = false);
// tasks is populated when includeDescription=false (the default): lean references, no
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
@@ -495,8 +497,8 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Update an existing task's title, description, commit type, and/or dependsOn link. Pass null to leave a " +
"field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
"Update an existing task's title, description, commit type, dependsOn link, and/or manual flag. Pass " +
"null to leave a field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> UpdateTask(
string taskId,
string? title = null,
@@ -506,6 +508,10 @@ public sealed class ExternalMcpService
"string to clear an existing link; null leaves it unchanged. Rejected if it doesn't exist, " +
"is this task's own id, or would create a dependency cycle.")]
string? dependsOnTaskId = null,
[Description("true: mark this task as a manual reminder only the user can complete — the queue picker, " +
"daily prep, and the list handler all skip it (a ConPTY session may still touch it). false: " +
"clear the flag. null (default): leave unchanged.")]
bool? isManual = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
@@ -517,6 +523,7 @@ public sealed class ExternalMcpService
if (title is not null) task.Title = title;
if (description is not null) task.Description = description;
if (commitType is not null) task.CommitType = commitType;
if (isManual is not null) task.IsManual = isManual.Value;
await _tasks.UpdateAsync(task, cancellationToken);
if (dependsOnTaskId is not null)
@@ -1743,7 +1750,8 @@ public sealed class ExternalMcpService
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
t.DependsOnTaskId,
blocked,
blockedReason);
blockedReason,
t.IsManual);
private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
t.Id,
@@ -1759,7 +1767,9 @@ public sealed class ExternalMcpService
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
t.DependsOnTaskId,
blocked,
blockedReason);
blockedReason,
BaseDirty: null,
IsManual: t.IsManual);
// "unknown" covers a Failed task that predates this field (never got a classified reason
// stamped) — a defined value rather than null so callers don't have to special-case it.
+31 -1
View File
@@ -13,11 +13,17 @@ public sealed class LifecycleMcpTools
{
private readonly TaskRepository _tasks;
private readonly TaskResetService _reset;
private readonly ExternalMcpService _svc;
private readonly InteractiveReviewSubmissionService _reviewSubmission;
public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset)
public LifecycleMcpTools(
TaskRepository tasks, TaskResetService reset, ExternalMcpService svc,
InteractiveReviewSubmissionService reviewSubmission)
{
_tasks = tasks;
_reset = reset;
_svc = svc;
_reviewSubmission = reviewSubmission;
}
[McpServerTool, Description(
@@ -37,4 +43,28 @@ public sealed class LifecycleMcpTools
await _reset.ResetAsync(taskId, cancellationToken);
return new ResetFailedTaskResult(true, taskId, task.Number);
}
[McpServerTool, Description(
"Submit a task into the review pipeline directly, without a headless agent run — the way a list " +
"handler ('Let Claude handle it') submits its OWN handler task once its run is done, since " +
"update_task_status doesn't reach WaitingForReview. Only Idle or Failed tasks are accepted; a " +
"Running/Queued/WaitingForReview/WaitingForChildren task throws. For a worktree-less handler task this " +
"stamps the review range's HandlerHeadCommit from the list's working dir HEAD (nothing to commit — the " +
"handler already committed straight to the list's working dir); for a task with an active worktree it " +
"commits any uncommitted changes there first. Either way the task then transitions to WaitingForReview " +
"and its diff becomes reviewable via review_task/get_task_diff." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
public async Task<TaskRefDto> SubmitTaskForReview(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
try
{
await _reviewSubmission.SubmitAsync(taskId, cancellationToken);
}
catch (KeyNotFoundException)
{
throw new InvalidOperationException($"Task {taskId} not found.");
}
return await _svc.GetTaskRefAsync(taskId, cancellationToken);
}
}
+7 -55
View File
@@ -227,6 +227,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly UsageSnapshotBuilder? _usageSnapshotBuilder;
private readonly ITranscriptUsageReader? _usageReader;
private readonly UsageMonitorService? _usageMonitor;
private readonly InteractiveReviewSubmissionService? _interactiveReviewSubmission;
public WorkerHub(
QueueService queue,
@@ -259,7 +260,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
Data.Git.GitService? git = null,
UsageSnapshotBuilder? usageSnapshotBuilder = null,
ITranscriptUsageReader? usageReader = null,
UsageMonitorService? usageMonitor = null)
UsageMonitorService? usageMonitor = null,
InteractiveReviewSubmissionService? interactiveReviewSubmission = null)
{
_queue = queue;
_waker = waker;
@@ -292,6 +294,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_usageSnapshotBuilder = usageSnapshotBuilder;
_usageReader = usageReader;
_usageMonitor = usageMonitor;
_interactiveReviewSubmission = interactiveReviewSubmission;
}
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
@@ -931,60 +934,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
// task status on its own.
public Task SubmitTaskForReview(string taskId) => HubGuard(async () =>
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var taskRepo = new TaskRepository(ctx);
var task = await taskRepo.GetByIdAsync(taskId, Context.ConnectionAborted)
?? throw new KeyNotFoundException();
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
throw new InvalidOperationException("Can't submit a running or queued task — interrupt it first.");
if (task.Status is TaskStatus.WaitingForReview or TaskStatus.WaitingForChildren)
throw new InvalidOperationException("Task is already awaiting review.");
// SubmitInteractiveForReviewAsync below only accepts Idle/Failed -- check it up front too,
// before either mutation branch, so a Done or Cancelled task can't get committed / have its
// HandlerHeadCommit stamped and then be rejected, stranding the work on an orphaned branch.
if (task.Status is not (TaskStatus.Idle or TaskStatus.Failed))
throw new InvalidOperationException("Task must be Idle or Failed to submit for review.");
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
if (worktree is not null)
{
if (_worktreeManager is null)
throw new InvalidOperationException("Worktree manager is not configured.");
if (worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
throw new InvalidOperationException("This task has no active worktree to submit.");
if (!Directory.Exists(worktree.Path))
throw new InvalidOperationException("The task's worktree directory no longer exists.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted)
?? throw new InvalidOperationException("Task list not found.");
var wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
await _worktreeManager.CommitIfChangedAsync(wtCtx, task, list, Context.ConnectionAborted);
}
else if (task.HandlerBaseCommit is { Length: > 0 })
{
// Worktree-less "list handler" host task (Mission Control's "Let Claude handle it"):
// the handler commits its own changes straight to the list's working dir, so there
// is nothing for us to commit here — just stamp the review range's head commit.
if (_git is null)
throw new InvalidOperationException("Git service is not configured.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted)
?? throw new InvalidOperationException("Task list not found.");
if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
throw new InvalidOperationException("The list's working directory no longer exists.");
var headCommit = await _git.RevParseHeadAsync(list.WorkingDir, Context.ConnectionAborted);
await taskRepo.SetHandlerHeadCommitAsync(taskId, headCommit, Context.ConnectionAborted);
}
else
{
throw new InvalidOperationException("This task has no active worktree to submit.");
}
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
if (_interactiveReviewSubmission is null)
throw new InvalidOperationException("Interactive review submission service is not configured.");
await _interactiveReviewSubmission.SubmitAsync(taskId, Context.ConnectionAborted);
});
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
@@ -0,0 +1,90 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Lifecycle;
// Submits an interactively-worked task -- one whose worktree/working-dir commits were made by a
// human-in-the-loop ConPTY session rather than an autonomous run -- into the review pipeline.
// Shared by WorkerHub.SubmitTaskForReview (a UI ConPTY session) and the external
// submit_task_for_review MCP tool (a list handler submitting its OWN handler task): both must
// stamp/commit identically before TaskStateService.SubmitInteractiveForReviewAsync flips the status.
public sealed class InteractiveReviewSubmissionService
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly ITaskStateService _state;
private readonly WorktreeManager? _worktreeManager;
public InteractiveReviewSubmissionService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
ITaskStateService state,
WorktreeManager? worktreeManager = null)
{
_dbFactory = dbFactory;
_git = git;
_state = state;
_worktreeManager = worktreeManager;
}
public async Task SubmitAsync(string taskId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var taskRepo = new TaskRepository(ctx);
var task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException();
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
throw new InvalidOperationException("Can't submit a running or queued task — interrupt it first.");
if (task.Status is TaskStatus.WaitingForReview or TaskStatus.WaitingForChildren)
throw new InvalidOperationException("Task is already awaiting review.");
// Check the status gate up front, before either mutation branch, so a Done or Cancelled
// task can't get committed / have its HandlerHeadCommit stamped and then be rejected,
// stranding the work on an orphaned branch.
if (task.Status is not (TaskStatus.Idle or TaskStatus.Failed))
throw new InvalidOperationException("Task must be Idle or Failed to submit for review.");
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
if (worktree is not null)
{
if (_worktreeManager is null)
throw new InvalidOperationException("Worktree manager is not configured.");
if (worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
throw new InvalidOperationException("This task has no active worktree to submit.");
if (!Directory.Exists(worktree.Path))
throw new InvalidOperationException("The task's worktree directory no longer exists.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("Task list not found.");
var wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
await _worktreeManager.CommitIfChangedAsync(wtCtx, task, list, ct);
}
else if (task.HandlerBaseCommit is { Length: > 0 })
{
// Worktree-less "list handler" host task (Mission Control's "Let Claude handle it"):
// the handler commits its own changes straight to the list's working dir, so there
// is nothing for us to commit here — just stamp the review range's head commit.
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("Task list not found.");
if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir))
throw new InvalidOperationException("The list's working directory no longer exists.");
var headCommit = await _git.RevParseHeadAsync(list.WorkingDir, ct);
await taskRepo.SetHandlerHeadCommitAsync(taskId, headCommit, ct);
}
else
{
throw new InvalidOperationException("This task has no active worktree to submit.");
}
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, ct);
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
}
}
+2
View File
@@ -93,6 +93,7 @@ builder.Services.AddSingleton<WorktreeMaintenanceService>();
builder.Services.AddSingleton<TaskResetService>();
builder.Services.AddSingleton<IVerifyCommandRunner, VerifyCommandRunner>();
builder.Services.AddSingleton<TaskMergeService>();
builder.Services.AddSingleton<InteractiveReviewSubmissionService>();
builder.Services.AddSingleton<PlanningAggregator>();
builder.Services.AddSingleton<PlanningMergeOrchestrator>();
builder.Services.AddSingleton<PlanningChainCoordinator>();
@@ -311,6 +312,7 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeMaintenanceService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskMergeService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<PlanningMergeOrchestrator>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<InteractiveReviewSubmissionService>());
externalBuilder.Services.AddScoped<ExternalMcpService>();
externalBuilder.Services.AddScoped<BatchMcpTools>();
externalBuilder.Services.AddScoped<ListMcpTools>();
@@ -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]
@@ -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;