feat(planning): consolidate finalize+chain via TaskStateService, fix queue pickup
Slice 4 of the worker state consolidation refactor. Eliminates the "queue never picks up planning tasks" bug structurally by routing both the manager and MCP finalize paths through TaskStateService and PlanningChainCoordinator.SetupChainAsync, where the auto-wake on enqueue guarantees the queue picker claims the first child immediately. - Delete TaskRepository.FinalizePlanningAsync; PlanningSessionManager now orchestrates via _state.FinalizePlanningAsync + _chain.SetupChainAsync. - Rename QueueSubtasksSequentiallyAsync to SetupChainAsync (internal); layout is now Status=Queued + BlockedByTaskId, with auto-attached agent tag. - OnChildFinishedAsync looks up the successor by BlockedByTaskId, drops the legacy Waiting status lookup. - PlanningMcpService.Finalize routes through state+chain; EditableStatuses drops Waiting and adds Idle; gate uses PlanningPhase==Active. - TaskStateService.FinalizePlanningAsync clears the planning session token. - UI: TaskRowViewModel adds BlockedByTaskId; IsQueued/IsWaiting reflect the new layout; TasksIslandViewModel.RemoveFromQueueAsync clears BlockedByTaskId on dequeue. - New regression test PlanningEndToEndTests.FinalizeAsync_FirstChildIs ClaimedByPicker_WithinDeadline asserts the picker claims the first child within 200ms with no manual WakeQueue. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -358,6 +358,7 @@ public sealed class TaskRepository
|
||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.Manual)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Planning)
|
||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.Active)
|
||||
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
||||
|
||||
if (affected == 0) return null;
|
||||
@@ -396,49 +397,6 @@ public sealed class TaskRepository
|
||||
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
||||
}
|
||||
|
||||
public async Task<int> FinalizePlanningAsync(
|
||||
string parentId,
|
||||
bool queueAgentTasks,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||||
|
||||
var parent = await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.List).ThenInclude(l => l.Tags)
|
||||
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||
if (parent is null || parent.Status != TaskStatus.Planning)
|
||||
throw new InvalidOperationException($"Task {parentId} is not in Planning state.");
|
||||
|
||||
var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent");
|
||||
|
||||
var drafts = await _context.Tasks
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
||||
.ToListAsync(ct);
|
||||
|
||||
int count = 0;
|
||||
foreach (var draft in drafts)
|
||||
{
|
||||
var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent");
|
||||
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
|
||||
draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual;
|
||||
count++;
|
||||
}
|
||||
|
||||
var finalizedAt = DateTime.UtcNow;
|
||||
await _context.Tasks
|
||||
.Where(t => t.Id == parentId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Planned)
|
||||
.SetProperty(t => t.PlanningFinalizedAt, finalizedAt)
|
||||
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<bool> DiscardPlanningAsync(
|
||||
string parentId,
|
||||
CancellationToken ct = default)
|
||||
@@ -462,6 +420,7 @@ public sealed class TaskRepository
|
||||
.Where(t => t.Id == parentId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Manual)
|
||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.None)
|
||||
.SetProperty(t => t.PlanningSessionId, (string?)null)
|
||||
.SetProperty(t => t.PlanningSessionToken, (string?)null)
|
||||
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _dropHintAbove;
|
||||
[ObservableProperty] private bool _dropHintBelow;
|
||||
[ObservableProperty] private string? _parentTaskId;
|
||||
[ObservableProperty] private string? _blockedByTaskId;
|
||||
[ObservableProperty] private bool _isExpanded = true;
|
||||
[ObservableProperty] private bool _hasPlanningChildren;
|
||||
[ObservableProperty] private bool _hasQueuedSubtasks;
|
||||
@@ -57,8 +58,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool HasSteps => StepsCount > 0;
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||
public bool IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsQueued => Status == TaskStatus.Queued;
|
||||
public bool IsWaiting => Status == TaskStatus.Waiting;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => (Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId))
|
||||
|| Status == TaskStatus.Waiting;
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
public bool HasSchedule => ScheduledFor.HasValue;
|
||||
public bool HasLiveTail => IsRunning && !string.IsNullOrEmpty(LiveTail);
|
||||
@@ -67,14 +69,15 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||
public string StepsText => $"{StepsCompleted}/{StepsCount} steps";
|
||||
|
||||
public string StatusChipClass => Status switch
|
||||
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
|
||||
{
|
||||
TaskStatus.Running => "running",
|
||||
TaskStatus.Failed => "error",
|
||||
TaskStatus.Done => "review",
|
||||
TaskStatus.Queued => "queued",
|
||||
TaskStatus.Waiting => "waiting",
|
||||
_ => "idle",
|
||||
(TaskStatus.Running, _) => "running",
|
||||
(TaskStatus.Failed, _) => "error",
|
||||
(TaskStatus.Done, _) => "review",
|
||||
(TaskStatus.Queued, true) => "waiting",
|
||||
(TaskStatus.Queued, false) => "queued",
|
||||
(TaskStatus.Waiting, _) => "waiting",
|
||||
_ => "idle",
|
||||
};
|
||||
|
||||
partial void OnStatusChanged(TaskStatus value)
|
||||
@@ -95,6 +98,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||
=> OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
|
||||
partial void OnBlockedByTaskIdChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
OnPropertyChanged(nameof(StatusChipClass));
|
||||
}
|
||||
|
||||
partial void OnParentTaskIdChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
@@ -125,18 +135,19 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public void UpdateFromEntity(TaskEntity t)
|
||||
{
|
||||
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
|
||||
Title = t.Title;
|
||||
ListName = t.List?.Name ?? "";
|
||||
Done = t.Status == TaskStatus.Done;
|
||||
IsStarred = t.IsStarred;
|
||||
IsMyDay = t.IsMyDay;
|
||||
Status = t.Status;
|
||||
Branch = t.Worktree?.BranchName;
|
||||
DiffStat = t.Worktree?.DiffStat;
|
||||
ScheduledFor = t.ScheduledFor;
|
||||
DiffAdditions = add;
|
||||
DiffDeletions = del;
|
||||
ParentTaskId = t.ParentTaskId;
|
||||
Title = t.Title;
|
||||
ListName = t.List?.Name ?? "";
|
||||
Done = t.Status == TaskStatus.Done;
|
||||
IsStarred = t.IsStarred;
|
||||
IsMyDay = t.IsMyDay;
|
||||
Status = t.Status;
|
||||
Branch = t.Worktree?.BranchName;
|
||||
DiffStat = t.Worktree?.DiffStat;
|
||||
ScheduledFor = t.ScheduledFor;
|
||||
DiffAdditions = add;
|
||||
DiffDeletions = del;
|
||||
ParentTaskId = t.ParentTaskId;
|
||||
BlockedByTaskId = t.BlockedByTaskId;
|
||||
}
|
||||
|
||||
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
||||
|
||||
@@ -495,18 +495,27 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
|
||||
// For a planning parent the dequeue button targets queued/waiting children,
|
||||
// not the parent itself (whose Status is Planning/Planned).
|
||||
if (entity.Status == TaskStatus.Planning || entity.Status == TaskStatus.Planned)
|
||||
if (entity.Status == TaskStatus.Planning || entity.Status == TaskStatus.Planned
|
||||
|| entity.PlanningPhase != PlanningPhase.None)
|
||||
{
|
||||
var children = await db.Tasks
|
||||
.Where(t => t.ParentTaskId == row.Id
|
||||
&& (t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting))
|
||||
.ToListAsync();
|
||||
foreach (var c in children) c.Status = TaskStatus.Manual;
|
||||
foreach (var c in children)
|
||||
{
|
||||
c.Status = TaskStatus.Manual;
|
||||
c.BlockedByTaskId = null;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
foreach (var c in children)
|
||||
{
|
||||
var childRow = Items.FirstOrDefault(r => r.Id == c.Id);
|
||||
if (childRow is not null) childRow.Status = TaskStatus.Manual;
|
||||
if (childRow is not null)
|
||||
{
|
||||
childRow.Status = TaskStatus.Manual;
|
||||
childRow.BlockedByTaskId = null;
|
||||
}
|
||||
}
|
||||
row.HasQueuedSubtasks = false;
|
||||
}
|
||||
|
||||
@@ -23,4 +23,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -88,7 +88,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
{
|
||||
try
|
||||
{
|
||||
await _planningChain.QueueSubtasksSequentiallyAsync(parentTaskId, Context.ConnectionAborted);
|
||||
await _planningChain.SetupChainAsync(parentTaskId, Context.ConnectionAborted);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
|
||||
@@ -19,7 +19,13 @@ public sealed class PlanningChainCoordinator
|
||||
_state = state;
|
||||
}
|
||||
|
||||
public async Task QueueSubtasksSequentiallyAsync(string parentTaskId, CancellationToken ct = default)
|
||||
// Sets up a sequential queue chain over a planning parent's children.
|
||||
// - First child gets Status=Queued (auto-wakes the queue picker).
|
||||
// - Each subsequent child gets Status=Queued + BlockedByTaskId=<predecessor>,
|
||||
// so the picker skips them until the predecessor finishes.
|
||||
// The "agent" tag is auto-attached to every child so the picker can claim them.
|
||||
// Returns the number of children placed in the chain.
|
||||
internal async Task<int> SetupChainAsync(string parentTaskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var parent = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == parentTaskId, ct)
|
||||
@@ -33,22 +39,38 @@ public sealed class PlanningChainCoordinator
|
||||
if (children.Count == 0)
|
||||
throw new InvalidOperationException("Parent has no subtasks.");
|
||||
|
||||
// Eligibility: new layout uses Status=Idle. Tolerate legacy Manual/Planned/Draft
|
||||
// values during this slice — they will be migrated away in slice 6.
|
||||
var bad = children.FirstOrDefault(c =>
|
||||
c.Status != TaskStatus.Manual && c.Status != TaskStatus.Planned);
|
||||
c.Status != TaskStatus.Idle &&
|
||||
c.Status != TaskStatus.Manual &&
|
||||
c.Status != TaskStatus.Planned &&
|
||||
c.Status != TaskStatus.Draft);
|
||||
if (bad is not null)
|
||||
throw new InvalidOperationException(
|
||||
$"Child {bad.Id} is in status {bad.Status}; expected Manual or Planned.");
|
||||
$"Child {bad.Id} is in status {bad.Status}; expected Idle (or legacy Manual/Planned/Draft).");
|
||||
|
||||
// Worker queue picker requires the "agent" tag — attach it so children are pickable.
|
||||
var agentTag = await ctx.Tags.FirstOrDefaultAsync(t => t.Name == "agent", ct);
|
||||
for (int i = 0; i < children.Count; i++)
|
||||
if (agentTag is not null)
|
||||
{
|
||||
children[i].Status = i == 0 ? TaskStatus.Queued : TaskStatus.Waiting;
|
||||
if (agentTag is not null && !children[i].Tags.Any(t => t.Id == agentTag.Id))
|
||||
children[i].Tags.Add(agentTag);
|
||||
foreach (var c in children)
|
||||
{
|
||||
if (!c.Tags.Any(t => t.Id == agentTag.Id))
|
||||
c.Tags.Add(agentTag);
|
||||
}
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
var state = _state();
|
||||
for (int i = 0; i < children.Count; i++)
|
||||
{
|
||||
await state.EnqueueAsync(children[i].Id, ct);
|
||||
if (i > 0)
|
||||
await state.BlockOnAsync(children[i].Id, children[i - 1].Id, ct);
|
||||
}
|
||||
|
||||
return children.Count;
|
||||
}
|
||||
|
||||
public async Task<string?> OnChildFinishedAsync(
|
||||
@@ -57,21 +79,18 @@ public sealed class PlanningChainCoordinator
|
||||
if (finalStatus != TaskStatus.Done) return null;
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var child = await ctx.Tasks
|
||||
// The successor is whichever sibling explicitly blocks on this child.
|
||||
// No status check — UnblockAsync flips legacy Waiting to Queued and is a no-op
|
||||
// for already-Queued rows in the new layout.
|
||||
var nextId = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == childTaskId, ct);
|
||||
if (child?.ParentTaskId is null) return null;
|
||||
|
||||
var next = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => t.ParentTaskId == child.ParentTaskId
|
||||
&& t.SortOrder > child.SortOrder
|
||||
&& t.Status == TaskStatus.Waiting)
|
||||
.OrderBy(t => t.SortOrder)
|
||||
.Where(t => t.BlockedByTaskId == childTaskId)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.Select(t => t.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (next is null) return null;
|
||||
if (nextId is null) return null;
|
||||
|
||||
await _state().UnblockAsync(next.Id, ct);
|
||||
return next.Id;
|
||||
await _state().UnblockAsync(nextId, ct);
|
||||
return nextId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ModelContextProtocol.Server;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -16,15 +17,21 @@ public sealed class PlanningMcpService
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly PlanningMcpContextAccessor _contextAccessor;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly PlanningChainCoordinator _chain;
|
||||
|
||||
public PlanningMcpService(
|
||||
TaskRepository tasks,
|
||||
PlanningMcpContextAccessor contextAccessor,
|
||||
HubBroadcaster broadcaster)
|
||||
HubBroadcaster broadcaster,
|
||||
ITaskStateService state,
|
||||
PlanningChainCoordinator chain)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_contextAccessor = contextAccessor;
|
||||
_broadcaster = broadcaster;
|
||||
_state = state;
|
||||
_chain = chain;
|
||||
}
|
||||
|
||||
private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
|
||||
@@ -61,9 +68,9 @@ public sealed class PlanningMcpService
|
||||
}
|
||||
|
||||
private static readonly TaskStatus[] EditableStatuses =
|
||||
{ TaskStatus.Draft, TaskStatus.Manual, TaskStatus.Queued, TaskStatus.Waiting };
|
||||
{ TaskStatus.Draft, TaskStatus.Idle, TaskStatus.Manual, TaskStatus.Queued };
|
||||
|
||||
[McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Draft, Manual, Queued, Waiting.")]
|
||||
[McpServerTool, Description("Update a child task in the active planning session. Can change title, description, tags (replaces the full set), commit type, and status. Status must be one of: Draft, Idle, Manual, Queued.")]
|
||||
public async Task<ChildTaskDto> UpdateChildTask(
|
||||
string taskId,
|
||||
string? title,
|
||||
@@ -76,7 +83,7 @@ public sealed class PlanningMcpService
|
||||
var ctx = _contextAccessor.Current;
|
||||
var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken)
|
||||
?? throw new InvalidOperationException("Planning parent task not found.");
|
||||
if (parent.Status != TaskStatus.Planning)
|
||||
if (parent.PlanningPhase != PlanningPhase.Active)
|
||||
throw new InvalidOperationException("Cannot modify tasks outside an active planning session.");
|
||||
|
||||
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -90,7 +97,7 @@ public sealed class PlanningMcpService
|
||||
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
||||
throw new InvalidOperationException($"Unknown status '{status}'.");
|
||||
if (!EditableStatuses.Contains(parsed))
|
||||
throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Draft, Manual, Queued, Waiting.");
|
||||
throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Draft, Idle, Manual, Queued.");
|
||||
newStatus = parsed;
|
||||
}
|
||||
|
||||
@@ -111,7 +118,7 @@ public sealed class PlanningMcpService
|
||||
var ctx = _contextAccessor.Current;
|
||||
var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken)
|
||||
?? throw new InvalidOperationException("Planning parent task not found.");
|
||||
if (parent.Status != TaskStatus.Planning)
|
||||
if (parent.PlanningPhase != PlanningPhase.Active)
|
||||
throw new InvalidOperationException("Cannot delete tasks outside an active planning session.");
|
||||
|
||||
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -141,11 +148,19 @@ public sealed class PlanningMcpService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var childIds = (await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken))
|
||||
.Select(c => c.Id).ToList();
|
||||
var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken);
|
||||
foreach (var id in childIds)
|
||||
await BroadcastTaskUpdatedAsync(id, cancellationToken);
|
||||
|
||||
var finalizeResult = await _state.FinalizePlanningAsync(ctx.ParentTaskId, cancellationToken);
|
||||
if (!finalizeResult.Ok)
|
||||
throw new InvalidOperationException(
|
||||
finalizeResult.Reason ?? $"Could not finalize planning for task {ctx.ParentTaskId}.");
|
||||
|
||||
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
||||
int count = children.Count;
|
||||
if (queueAgentTasks && children.Count > 0)
|
||||
count = await _chain.SetupChainAsync(ctx.ParentTaskId, cancellationToken);
|
||||
|
||||
foreach (var c in children)
|
||||
await BroadcastTaskUpdatedAsync(c.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class PlanningSessionManager
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly string _rootDirectory;
|
||||
private readonly ITaskStateService? _state;
|
||||
private readonly PlanningChainCoordinator? _chain;
|
||||
|
||||
// DI constructor.
|
||||
public PlanningSessionManager(
|
||||
@@ -31,12 +32,14 @@ public sealed class PlanningSessionManager
|
||||
GitService git,
|
||||
WorkerConfig cfg,
|
||||
ITaskStateService state,
|
||||
PlanningChainCoordinator chain,
|
||||
string rootDirectory)
|
||||
{
|
||||
_factory = factory;
|
||||
_git = git;
|
||||
_cfg = cfg;
|
||||
_state = state;
|
||||
_chain = chain;
|
||||
_rootDirectory = rootDirectory;
|
||||
}
|
||||
|
||||
@@ -48,7 +51,8 @@ public sealed class PlanningSessionManager
|
||||
GitService git,
|
||||
WorkerConfig cfg,
|
||||
string rootDirectory,
|
||||
ITaskStateService? state = null)
|
||||
ITaskStateService? state = null,
|
||||
PlanningChainCoordinator? chain = null)
|
||||
{
|
||||
_tasksOverride = tasks;
|
||||
_listsOverride = lists;
|
||||
@@ -56,6 +60,7 @@ public sealed class PlanningSessionManager
|
||||
_git = git;
|
||||
_cfg = cfg;
|
||||
_state = state;
|
||||
_chain = chain;
|
||||
_rootDirectory = rootDirectory;
|
||||
}
|
||||
|
||||
@@ -194,7 +199,21 @@ public sealed class PlanningSessionManager
|
||||
var (tasks, lists, settings, ctx) = CreateRepos();
|
||||
await using var __ = ctx;
|
||||
|
||||
var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
|
||||
if (_state is null || _chain is null)
|
||||
throw new InvalidOperationException(
|
||||
"PlanningSessionManager.FinalizeAsync requires ITaskStateService and PlanningChainCoordinator.");
|
||||
|
||||
var finalizeResult = await _state.FinalizePlanningAsync(taskId, ct);
|
||||
if (!finalizeResult.Ok)
|
||||
throw new InvalidOperationException(
|
||||
finalizeResult.Reason ?? $"Could not finalize planning for task {taskId}.");
|
||||
|
||||
int count = 0;
|
||||
var children = await tasks.GetChildrenAsync(taskId, ct);
|
||||
if (queueAgentTasks && children.Count > 0)
|
||||
count = await _chain.SetupChainAsync(taskId, ct);
|
||||
else
|
||||
count = children.Count;
|
||||
|
||||
// Best-effort cleanup — don't block finalization on git state.
|
||||
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||
|
||||
@@ -83,6 +83,7 @@ builder.Services.AddSingleton(sp =>
|
||||
sp.GetRequiredService<GitService>(),
|
||||
cfg,
|
||||
sp.GetRequiredService<ITaskStateService>(),
|
||||
sp.GetRequiredService<PlanningChainCoordinator>(),
|
||||
planningSessionsDir));
|
||||
builder.Services.AddSingleton<IPlanningTerminalLauncher>(sp =>
|
||||
new WindowsTerminalPlanningLauncher("wt.exe", cfg.ClaudeBin));
|
||||
|
||||
@@ -164,7 +164,8 @@ public sealed class TaskStateService : ITaskStateService
|
||||
.Where(t => t.Id == parentId && t.PlanningPhase == PlanningPhase.Active)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized)
|
||||
.SetProperty(t => t.PlanningFinalizedAt, DateTime.UtcNow), ct);
|
||||
.SetProperty(t => t.PlanningFinalizedAt, DateTime.UtcNow)
|
||||
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
|
||||
|
||||
if (affected == 0)
|
||||
return new TransitionResult(false, "No active planning session.");
|
||||
|
||||
Reference in New Issue
Block a user