diff --git a/src/ClaudeDo.Data/PromptFiles.cs b/src/ClaudeDo.Data/PromptFiles.cs index 67298121..4e604252 100644 --- a/src/ClaudeDo.Data/PromptFiles.cs +++ b/src/ClaudeDo.Data/PromptFiles.cs @@ -530,9 +530,9 @@ public static class PromptFiles ## Handoff / end - - If you started any reruns: call handoff_list_handler(taskId, , 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, , 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: diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index fcfea6f2..2902b100 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -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 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. diff --git a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs index 7e56d812..d0ede3fc 100644 --- a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs +++ b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs @@ -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 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); + } } diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 44e997bd..504d792b 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -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 DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false) diff --git a/src/ClaudeDo.Worker/Lifecycle/InteractiveReviewSubmissionService.cs b/src/ClaudeDo.Worker/Lifecycle/InteractiveReviewSubmissionService.cs new file mode 100644 index 00000000..b41c4315 --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/InteractiveReviewSubmissionService.cs @@ -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 _dbFactory; + private readonly GitService _git; + private readonly ITaskStateService _state; + private readonly WorktreeManager? _worktreeManager; + + public InteractiveReviewSubmissionService( + IDbContextFactory 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."); + } +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 2ec1916d..17685a00 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -93,6 +93,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -311,6 +312,7 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 19978591..665d73f0 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -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(() => - 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(() => - sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None)); + sut.UpdateTask("does-not-exist", "x", null, null, null, null, CancellationToken.None)); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs index 5708d7c3..eb70f5dd 100644 --- a/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs @@ -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 _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.Instance); + var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger.Instance); var state = TaskStateServiceBuilder.Build(dbFactory).State; var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger.Instance); - return new LifecycleMcpTools(_tasks, reset); + var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger.Instance); + var merge = new TaskMergeService(dbFactory, git, broadcaster, state, new VerifyCommandRunner(), NullLogger.Instance); + var aggregator = new PlanningAggregator(dbFactory, git, NullLogger.Instance); + var planningMerge = new PlanningMergeOrchestrator( + dbFactory, merge, aggregator, broadcaster, git, state, NullLogger.Instance); + var svc = new ExternalMcpService( + _tasks, _lists, CreateQueue(dbFactory, wtManager, state, broadcaster), broadcaster, + state, git, dbFactory, maintenance, merge, planningMerge, + new BaseDirtyChecker(git, NullLogger.Instance)); + var reviewSubmission = new InteractiveReviewSubmissionService(dbFactory, git, state, wtManager); + return new LifecycleMcpTools(_tasks, reset, svc, reviewSubmission); } - private async Task SeedTaskAsync(TaskStatus status) + private QueueService CreateQueue( + IDbContextFactory 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.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); + var runCancels = new RunCancellationRegistry(NullLogger.Instance); + var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, runCancels); + return new QueueService(dbFactory, runner, cfg, NullLogger.Instance, + new QueueWaker(), new QueuePicker(dbFactory), overrideSlot, state, runCancels, + new FakeUsageGate(), new UsageState(), broadcaster); + } + + private async Task 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(() => 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(() => + 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(() => + 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(() => + 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(() => + sut.SubmitTaskForReview("missing", CancellationToken.None)); + } } diff --git a/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs index 09d9bdf8..35038e78 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs @@ -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;