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:
Mika Kuns
2026-04-27 14:16:12 +02:00
parent 064a903076
commit 4ab906ff0b
17 changed files with 315 additions and 206 deletions

View File

@@ -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;
}
}