Theme 3: autonomous Run all — fan a big task's children across the AI seats

New POST /api/orgboard/tasks/{id}/run-all dispatches a run for every
outstanding child of a task (not done, no artifact yet), round-robined across
the team's configured AI seats, or all to one seat if specified. Agents still
act per their own autonomy, so gated work lands in review. The board task
drawer gets a Run all with AI button on any task that has children, so one
click puts the whole breakdown to work — then watch progress on the Delivery
dashboard and download the result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-17 07:36:59 +03:30
parent 0658061580
commit 5c2b697b66
3 changed files with 104 additions and 2 deletions
+25 -2
View File
@@ -8,7 +8,7 @@ import {
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import { Bot, Play, Plus, Trash2 } from 'lucide-react'
import { Bot, Play, Plus, Sparkles, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell, REVIEWS_CHANGED } from '@/components/AppShell'
import { LivePreview } from '@/components/LivePreview'
@@ -569,7 +569,30 @@ function TaskDrawer({
{children.length > 0 && (
<div className="flex flex-col gap-2">
<Label>Child tasks</Label>
<div className="flex items-center justify-between">
<Label>Child tasks</Label>
{aiSeats.length > 0 && (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() =>
act(async () => {
const res = await api.post<{ dispatched: number }>(
`/api/orgboard/tasks/${task.id}/run-all`,
seatId ? { seatId } : {},
)
if (res.dispatched === 0) throw new Error('Nothing left to run — all children are done or delivered.')
}, 'Autopilot started — running every outstanding child on the AI seats.').then(() => {
window.dispatchEvent(new Event(REVIEWS_CHANGED))
setTimeout(() => window.dispatchEvent(new Event(REVIEWS_CHANGED)), 5000)
})
}
>
<Sparkles data-icon="inline-start" /> Run all with AI
</Button>
)}
</div>
<div className="flex flex-col gap-1.5">
{children.map((child) => (
<button
@@ -29,6 +29,13 @@ internal sealed record MoveTaskRequest(WorkItemStatus Status);
internal sealed record AssignTaskRequest(Guid MemberId);
// Autopilot: fan a parent task's outstanding children out to the team's AI seats in one shot.
internal sealed record RunAllRequest(Guid? SeatId = null);
internal sealed record RunDispatch(Guid WorkItemId, string Title, Guid SeatId, Guid RunId);
internal sealed record RunAllResponse(int Dispatched, IReadOnlyList<RunDispatch> Runs);
internal sealed record TaskResponse(
Guid Id,
Guid TeamId,
@@ -9,6 +9,7 @@ using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Ai;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
@@ -35,6 +36,7 @@ internal static class OrgBoardEndpoints
group.MapGet("/board", GetBoard).RequireAuthorization();
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
group.MapPost("/tasks/{id:guid}/run-all", RunAllChildren).RequireAuthorization();
group.MapDelete("/tasks/{id:guid}", DeleteTask).RequireAuthorization();
group.MapGet("/cartable", Cartable).RequireAuthorization();
@@ -538,6 +540,76 @@ internal static class OrgBoardEndpoints
return Results.Ok(ToResponse(item));
}
// Autopilot for a big task: dispatch a run for every outstanding child in one shot, fanning them
// across the team's configured AI seats (round-robin), or all to one seat if specified. Children
// that already carry an artifact or are done are skipped, so re-running only picks up what's left.
// The agents still act per their own autonomy — gated ones land in review; this just starts them all.
private static async Task<IResult> RunAllChildren(
Guid id, RunAllRequest request, ICurrentUser user, IPermissionService permissions,
IAgentDispatcher dispatcher, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var parent = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == id, ct);
if (parent is null)
{
return Results.NotFound("Task not found.");
}
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == parent.TeamId, ct);
if (team is null)
{
return Results.NotFound("Team not found.");
}
if (!permissions.Has(Capability.WorkTasks, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
{
return Results.Forbid();
}
// Outstanding children: not done and no delivered artifact yet.
var children = await db.WorkItems
.Where(w => w.ParentId == id
&& w.Status != WorkItemStatus.Done
&& (w.Description == null || w.Description == ""))
.OrderBy(w => w.CreatedAtUtc)
.ToListAsync(ct);
if (children.Count == 0)
{
return Results.Ok(new RunAllResponse(0, Array.Empty<RunDispatch>()));
}
// The pool of AI seats to run them on: configured AI seats on the team (optionally just one).
var seats = await db.Seats
.Where(s => s.TeamId == team.Id && s.State == SeatState.Ai && s.AgentId != null)
.OrderBy(s => s.CreatedAtUtc)
.ToListAsync(ct);
if (request.SeatId is { } chosen)
{
seats = seats.Where(s => s.Id == chosen).ToList();
}
if (seats.Count == 0)
{
return Results.BadRequest("No configured AI seat on this team to run the tasks.");
}
var now = clock.GetUtcNow();
var dispatched = new List<RunDispatch>(children.Count);
for (var i = 0; i < children.Count; i++)
{
var seat = seats[i % seats.Count];
var runId = await dispatcher.DispatchAsync(seat.Id, children[i].Id, ct);
if (children[i].Status == WorkItemStatus.Backlog)
{
children[i].MoveTo(WorkItemStatus.InProgress, now);
}
dispatched.Add(new RunDispatch(children[i].Id, children[i].Title, seat.Id, runId));
}
await db.SaveChangesAsync(ct);
return Results.Ok(new RunAllResponse(dispatched.Count, dispatched));
}
private static async Task<IResult> Cartable(ICurrentUser user, OrgBoardDbContext db, CancellationToken ct)
{
var memberId = user.MemberId;