mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Bunch of runtime fixes for MegaTask and other issues
This commit is contained in:
@@ -4,10 +4,12 @@ import { useEffect, useRef } from "react";
|
|||||||
import { Loader2, Sparkles, X } from "lucide-react";
|
import { Loader2, Sparkles, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { usePrompter } from "@/hooks/use-prompter";
|
import { usePrompter } from "@/hooks/use-prompter";
|
||||||
|
import { Team } from "@/types";
|
||||||
import {
|
import {
|
||||||
ChatMessages,
|
ChatMessages,
|
||||||
ChatComposer,
|
ChatComposer,
|
||||||
SuccessCard,
|
SuccessCard,
|
||||||
|
BoardReviewSentCard,
|
||||||
IntakeForm,
|
IntakeForm,
|
||||||
BatchReviewCard,
|
BatchReviewCard,
|
||||||
} from "@/components/prompter";
|
} from "@/components/prompter";
|
||||||
@@ -42,7 +44,7 @@ export default function PrompterPage() {
|
|||||||
batch,
|
batch,
|
||||||
batchWaves,
|
batchWaves,
|
||||||
batchResult,
|
batchResult,
|
||||||
updateBatchDraftProject,
|
setBatchDraftProjects,
|
||||||
confirmBatch,
|
confirmBatch,
|
||||||
} = usePrompter();
|
} = usePrompter();
|
||||||
|
|
||||||
@@ -113,22 +115,40 @@ export default function PrompterPage() {
|
|||||||
createdTaskTeam ? (
|
createdTaskTeam ? (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
|
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
|
||||||
<div className="w-full max-w-md space-y-3">
|
<div className="w-full max-w-md space-y-3">
|
||||||
<SuccessCard
|
{/* Board-routed MegaTask: created HELD for PO+HoM review, not
|
||||||
taskId={createdTaskId}
|
dispatched — the CEO releases it with Approve & Start on the
|
||||||
taskTitle={createdTaskTitle}
|
umbrella task. Every other path is a real "created/launched"
|
||||||
team={createdTaskTeam}
|
success. ``createdTaskTeam === BOARD`` is set only by the
|
||||||
onStartAnother={startAnother}
|
batch board route (the single-draft board route parks and
|
||||||
/>
|
never reaches success). */}
|
||||||
{batchResult && (
|
{createdTaskTeam === Team.BOARD && batchResult ? (
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<BoardReviewSentCard
|
||||||
{batchResult.root_subtask_ids.length} tasks sequenced into{" "}
|
taskId={createdTaskId}
|
||||||
{batchResult.waves.length} wave
|
taskTitle={createdTaskTitle}
|
||||||
{batchResult.waves.length === 1 ? "" : "s"}.
|
rootSubtaskCount={batchResult.root_subtask_ids.length}
|
||||||
{batchResult.warnings.length > 0 &&
|
waveCount={batchResult.waves.length}
|
||||||
` ${batchResult.warnings.length} advisory note${
|
onStartAnother={startAnother}
|
||||||
batchResult.warnings.length === 1 ? "" : "s"
|
/>
|
||||||
}.`}
|
) : (
|
||||||
</p>
|
<>
|
||||||
|
<SuccessCard
|
||||||
|
taskId={createdTaskId}
|
||||||
|
taskTitle={createdTaskTitle}
|
||||||
|
team={createdTaskTeam}
|
||||||
|
onStartAnother={startAnother}
|
||||||
|
/>
|
||||||
|
{batchResult && (
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
{batchResult.root_subtask_ids.length} tasks sequenced
|
||||||
|
into {batchResult.waves.length} wave
|
||||||
|
{batchResult.waves.length === 1 ? "" : "s"}.
|
||||||
|
{batchResult.warnings.length > 0 &&
|
||||||
|
` ${batchResult.warnings.length} advisory note${
|
||||||
|
batchResult.warnings.length === 1 ? "" : "s"
|
||||||
|
}.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +169,7 @@ export default function PrompterPage() {
|
|||||||
waves={batchWaves}
|
waves={batchWaves}
|
||||||
projectIds={projectIds}
|
projectIds={projectIds}
|
||||||
onKeepChatting={keepChatting}
|
onKeepChatting={keepChatting}
|
||||||
onProjectChange={updateBatchDraftProject}
|
onSetProjects={setBatchDraftProjects}
|
||||||
onConfirm={confirmBatch}
|
onConfirm={confirmBatch}
|
||||||
isLaunching={isLaunching}
|
isLaunching={isLaunching}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,15 +11,10 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { useProjects } from "@/hooks/use-projects";
|
import { useProjects } from "@/hooks/use-projects";
|
||||||
import type { BatchProposal, StartRoute } from "@/hooks/use-prompter";
|
import type { BatchProposal, StartRoute } from "@/hooks/use-prompter";
|
||||||
|
import type { ProjectSummary } from "@/types";
|
||||||
import type { CellWork, DraftProposal } from "@/lib/api/prompter";
|
import type { CellWork, DraftProposal } from "@/lib/api/prompter";
|
||||||
import { Team } from "@/types";
|
import { Team } from "@/types";
|
||||||
|
|
||||||
@@ -32,38 +27,40 @@ const CELL_LABEL: Record<string, string> = {
|
|||||||
ux_ui: "UX/UI",
|
ux_ui: "UX/UI",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** One the_work entry whose team is a delivery cell — a per-cell project picker. */
|
/** The project_ids a draft currently targets: the per-cell ``the_work[].project_id``
|
||||||
interface CellEntry {
|
* set (the multi-select model), falling back to a legacy top-level project_id.
|
||||||
entry: CellWork;
|
* Only scoped ids count — an out-of-scope id is treated as unselected. */
|
||||||
entryIndex: number;
|
function selectedProjectIds(
|
||||||
team: Team;
|
draft: DraftProposal,
|
||||||
}
|
scoped: Set<string>,
|
||||||
|
): string[] {
|
||||||
/** A draft's per-cell entries (the the_work slots that carry a cell team), in
|
const work = Array.isArray(draft.the_work) ? draft.the_work : [];
|
||||||
* the_work order. Empty for a legacy single-cell draft with no cell the_work. */
|
const pids = work
|
||||||
function cellEntries(draft: DraftProposal): CellEntry[] {
|
|
||||||
return (draft.the_work ?? [])
|
|
||||||
.map((entry, entryIndex) => ({ entry, entryIndex, team: entry?.team }))
|
|
||||||
.filter(
|
.filter(
|
||||||
(e): e is CellEntry =>
|
(w): w is CellWork & { project_id: string } =>
|
||||||
!!e.team && (CELL_TEAMS as readonly string[]).includes(e.team),
|
!!w?.team &&
|
||||||
);
|
(CELL_TEAMS as readonly string[]).includes(w.team) &&
|
||||||
|
typeof w.project_id === "string" &&
|
||||||
|
w.project_id !== "" &&
|
||||||
|
scoped.has(w.project_id),
|
||||||
|
)
|
||||||
|
.map((w) => w.project_id);
|
||||||
|
if (pids.length > 0) return pids;
|
||||||
|
if (draft.project_id && scoped.has(draft.project_id))
|
||||||
|
return [draft.project_id];
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BatchReviewCardProps {
|
interface BatchReviewCardProps {
|
||||||
batch: BatchProposal;
|
batch: BatchProposal;
|
||||||
/** The conflict-free waves (lists of draft indices), once previewed. */
|
/** The conflict-free waves (lists of draft indices), once previewed. */
|
||||||
waves: number[][] | null;
|
waves: number[][] | null;
|
||||||
/** The repos this MegaTask is scoped to — each cell must target one of them. */
|
/** The repos this MegaTask is scoped to — each task targets a subset of them. */
|
||||||
projectIds: string[];
|
projectIds: string[];
|
||||||
onKeepChatting: () => void;
|
onKeepChatting: () => void;
|
||||||
/** `entryIndex` is the the_work slot (the cell); -1 for a legacy single-cell
|
/** Set the whole set of projects one task targets (multi-select across cells,
|
||||||
* draft with no per-cell map (sets the top-level project_id). */
|
* one repo per cell — the backend stores one project per cell). */
|
||||||
onProjectChange: (
|
onSetProjects: (index: number, ids: string[]) => void;
|
||||||
index: number,
|
|
||||||
entryIndex: number,
|
|
||||||
projectId: string,
|
|
||||||
) => void;
|
|
||||||
onConfirm: (route: StartRoute) => void;
|
onConfirm: (route: StartRoute) => void;
|
||||||
/** A launch is in flight — disable the actions so a double-click can't dupe. */
|
/** A launch is in flight — disable the actions so a double-click can't dupe. */
|
||||||
isLaunching?: boolean;
|
isLaunching?: boolean;
|
||||||
@@ -71,38 +68,55 @@ interface BatchReviewCardProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The MegaTask review card: every task the agent proposed in one batch, each
|
* The MegaTask review card: every task the agent proposed in one batch, each
|
||||||
* with its per-cell target projects (editable) and collision surface, plus the
|
* with its target projects (a multi-select checkbox list — one task can span
|
||||||
* conflict-free wave plan. A multi-cell task (be+fe, fe+uxui) shows one project
|
* several repos, one repo per delivery cell) and collision surface, plus the
|
||||||
* picker per cell, scoped to that cell's repos — a RoboCo project is per-cell,
|
* conflict-free wave plan. The human reviews the whole batch and the sequencing,
|
||||||
* so each cell lands in its own repo. The human reviews the whole batch and the
|
* picks the repos each task lands in, then picks one start path.
|
||||||
* sequencing, fixes any cell in the wrong repo, then picks one start path.
|
|
||||||
*/
|
*/
|
||||||
export function BatchReviewCard({
|
export function BatchReviewCard({
|
||||||
batch,
|
batch,
|
||||||
waves,
|
waves,
|
||||||
projectIds,
|
projectIds,
|
||||||
onKeepChatting,
|
onKeepChatting,
|
||||||
onProjectChange,
|
onSetProjects,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
isLaunching = false,
|
isLaunching = false,
|
||||||
}: BatchReviewCardProps) {
|
}: BatchReviewCardProps) {
|
||||||
const { data: allProjects = [] } = useProjects();
|
const { data: allProjects = [] } = useProjects();
|
||||||
// Only the scoped repos are valid targets (the agent read only those).
|
// Only the scoped repos are valid targets (the agent read only those).
|
||||||
const scoped = new Set(projectIds);
|
const scoped = new Set(projectIds);
|
||||||
|
const scopedByCell = (cell: Team): ProjectSummary[] =>
|
||||||
|
allProjects.filter((p) => scoped.has(p.id) && p.assigned_cell === cell);
|
||||||
const titleOf = (i: number): string =>
|
const titleOf = (i: number): string =>
|
||||||
batch.drafts[i]?.title ?? `Task ${i + 1}`;
|
batch.drafts[i]?.title ?? `Task ${i + 1}`;
|
||||||
// A task is mis-targeted when any of its cells lacks a scoped project (a
|
// A task is mis-targeted when it has no project selected at all (the backend
|
||||||
// multi-cell draft checks every the_work entry; a legacy single-cell draft
|
// re-asserts each targeted project is in scope and the batch spans ≥2 repos).
|
||||||
// with no cell map checks its top-level project_id).
|
const missingProject = batch.drafts.some(
|
||||||
const missingProject = batch.drafts.some((d) => {
|
(d) => selectedProjectIds(d, scoped).length === 0,
|
||||||
const entries = cellEntries(d);
|
);
|
||||||
if (entries.length > 0) {
|
|
||||||
return entries.some(
|
/** Toggle one project in a task's selection. A RoboCo project is per-cell and
|
||||||
(ce) => !ce.entry.project_id || !scoped.has(ce.entry.project_id),
|
* the backend stores one project per cell, so checking a repo in a cell that
|
||||||
|
* already has a different repo checked swaps it (unchecks the sibling). */
|
||||||
|
const toggle = (index: number, projectId: string) => {
|
||||||
|
const draft = batch.drafts[index];
|
||||||
|
const current = selectedProjectIds(draft, scoped);
|
||||||
|
const proj = allProjects.find((p) => p.id === projectId);
|
||||||
|
const cell = proj?.assigned_cell;
|
||||||
|
if (current.includes(projectId)) {
|
||||||
|
onSetProjects(
|
||||||
|
index,
|
||||||
|
current.filter((id) => id !== projectId),
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return !d.project_id || !scoped.has(d.project_id);
|
// Checking: drop any other project in the same cell (one repo per cell).
|
||||||
});
|
const next = current.filter((id) => {
|
||||||
|
const other = allProjects.find((p) => p.id === id);
|
||||||
|
return other?.assigned_cell !== cell;
|
||||||
|
});
|
||||||
|
onSetProjects(index, [...next, projectId]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-primary/40 bg-primary/5">
|
<Card className="border-primary/40 bg-primary/5">
|
||||||
@@ -124,14 +138,22 @@ export function BatchReviewCard({
|
|||||||
<CardContent className="space-y-3 pb-3">
|
<CardContent className="space-y-3 pb-3">
|
||||||
<ol className="space-y-2">
|
<ol className="space-y-2">
|
||||||
{batch.drafts.map((draft, i) => {
|
{batch.drafts.map((draft, i) => {
|
||||||
const entries = cellEntries(draft);
|
const selected = selectedProjectIds(draft, scoped);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-md border bg-background/60 px-3 py-2 text-sm"
|
className="rounded-md border bg-background/60 px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<span className="font-medium leading-tight">
|
{/* Title is clamped to a fixed 2-line space so a long title
|
||||||
|
can't grow the row (or, as a long unbroken token, blow the
|
||||||
|
card width out and wreck the layout). min-w-0 lets the flex
|
||||||
|
item shrink below min-content; break-words stops a token
|
||||||
|
from overflowing; the full title is on the tooltip. */}
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 break-words font-medium leading-tight line-clamp-2"
|
||||||
|
title={`${i + 1}. ${draft.title}`}
|
||||||
|
>
|
||||||
{i + 1}. {draft.title}
|
{i + 1}. {draft.title}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
@@ -154,87 +176,48 @@ export function BatchReviewCard({
|
|||||||
{draft.objective || draft.description}
|
{draft.objective || draft.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{entries.length > 0 ? (
|
{/* Multi-select project picker — one task can span several repos
|
||||||
/* Per-cell project picker — one Select per the_work entry,
|
(one per delivery cell). Grouped by cell; one repo per cell. */}
|
||||||
scoped to that cell's repos (a project is per-cell). */
|
<div className="mt-1.5 space-y-1.5">
|
||||||
<div className="mt-1.5 space-y-1">
|
<p
|
||||||
{entries.map(({ entry, entryIndex, team }) => {
|
className={`text-xs ${
|
||||||
const cellProjects = allProjects.filter(
|
selected.length === 0
|
||||||
(p) => scoped.has(p.id) && p.assigned_cell === team,
|
? "text-destructive"
|
||||||
);
|
: "text-muted-foreground"
|
||||||
const pid = entry.project_id ?? "";
|
}`}
|
||||||
const ok = pid !== "" && scoped.has(pid);
|
>
|
||||||
return (
|
Projects {selected.length === 0 && "— pick at least one"}
|
||||||
<div
|
</p>
|
||||||
key={entryIndex}
|
{CELL_TEAMS.map((cell) => {
|
||||||
className="flex items-center gap-2"
|
const repos = scopedByCell(cell);
|
||||||
>
|
if (repos.length === 0) return null;
|
||||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">
|
return (
|
||||||
{CELL_LABEL[team] ?? team}
|
<div key={cell} className="space-y-1">
|
||||||
</span>
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<Select
|
{CELL_LABEL[cell] ?? cell}
|
||||||
value={ok ? pid : ""}
|
</span>
|
||||||
onValueChange={(v) =>
|
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||||
onProjectChange(i, entryIndex, v)
|
{repos.map((p) => {
|
||||||
}
|
const checked = selected.includes(p.id);
|
||||||
disabled={isLaunching}
|
return (
|
||||||
>
|
<label
|
||||||
<SelectTrigger
|
key={p.id}
|
||||||
className={`h-7 flex-1 text-xs ${
|
className="flex cursor-pointer items-center gap-1.5 text-xs disabled:cursor-not-allowed"
|
||||||
ok ? "" : "border-destructive"
|
>
|
||||||
}`}
|
<Checkbox
|
||||||
>
|
checked={checked}
|
||||||
<SelectValue placeholder="Pick a project…" />
|
disabled={isLaunching}
|
||||||
</SelectTrigger>
|
onCheckedChange={() => toggle(i, p.id)}
|
||||||
<SelectContent>
|
/>
|
||||||
{cellProjects.map((p) => (
|
<span>{p.name}</span>
|
||||||
<SelectItem key={p.id} value={p.id}>
|
</label>
|
||||||
{p.name}
|
);
|
||||||
</SelectItem>
|
})}
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
) : (
|
</div>
|
||||||
/* Legacy single-cell draft (no per-cell the_work) — one Select
|
|
||||||
bound to the top-level project_id, scoped to all repos. */
|
|
||||||
<div className="mt-1.5 flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Project
|
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
value={
|
|
||||||
draft.project_id && scoped.has(draft.project_id)
|
|
||||||
? draft.project_id
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onValueChange={(v) => onProjectChange(i, -1, v)}
|
|
||||||
disabled={isLaunching}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className={`h-7 flex-1 text-xs ${
|
|
||||||
draft.project_id && scoped.has(draft.project_id)
|
|
||||||
? ""
|
|
||||||
: "border-destructive"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Pick a project…" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{allProjects
|
|
||||||
.filter((p) => scoped.has(p.id))
|
|
||||||
.map((p) => (
|
|
||||||
<SelectItem key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -248,7 +231,11 @@ export function BatchReviewCard({
|
|||||||
</p>
|
</p>
|
||||||
<ol className="space-y-0.5">
|
<ol className="space-y-0.5">
|
||||||
{waves.map((wave, w) => (
|
{waves.map((wave, w) => (
|
||||||
<li key={w} className="text-xs">
|
<li
|
||||||
|
key={w}
|
||||||
|
className="break-words text-xs line-clamp-2"
|
||||||
|
title={wave.map((i) => titleOf(i)).join(", ")}
|
||||||
|
>
|
||||||
<span className="font-medium">Wave {w + 1}:</span>{" "}
|
<span className="font-medium">Wave {w + 1}:</span>{" "}
|
||||||
{wave.map((i) => titleOf(i)).join(", ")}
|
{wave.map((i) => titleOf(i)).join(", ")}
|
||||||
</li>
|
</li>
|
||||||
@@ -259,7 +246,7 @@ export function BatchReviewCard({
|
|||||||
|
|
||||||
{missingProject && (
|
{missingProject && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
Pick a project for every cell of every task before launching the
|
Pick at least one project for every task before launching the
|
||||||
MegaTask.
|
MegaTask.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Users, ExternalLink, RefreshCw } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface BoardReviewSentCardProps {
|
||||||
|
/** The umbrella task id — the single board-review / CEO-approve unit. */
|
||||||
|
taskId: string;
|
||||||
|
taskTitle: string;
|
||||||
|
rootSubtaskCount: number;
|
||||||
|
waveCount: number;
|
||||||
|
onStartAnother: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MegaTask "Board review & Start" confirmation: the umbrella + root-subtasks
|
||||||
|
* were created HELD (umbrella assigned to the Product Owner, root-subtasks in
|
||||||
|
* BACKLOG) for the Product Owner + Head of Marketing to review. Nothing is
|
||||||
|
* dispatched yet — the CEO releases the sequenced tasks with Approve & Start on
|
||||||
|
* the umbrella task once the board finishes. This is the batch analogue of the
|
||||||
|
* single-draft board route's "sent to the board" wait, reusing the existing CEO
|
||||||
|
* Approve & Start gate on the umbrella (which fires ``approve_and_start`` →
|
||||||
|
* ``_activate_batch_root_subtasks``).
|
||||||
|
*/
|
||||||
|
export function BoardReviewSentCard({
|
||||||
|
taskId,
|
||||||
|
taskTitle,
|
||||||
|
rootSubtaskCount,
|
||||||
|
waveCount,
|
||||||
|
onStartAnother,
|
||||||
|
}: BoardReviewSentCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-primary/30 bg-primary/5">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5 text-primary" />
|
||||||
|
<CardTitle className="text-sm font-semibold text-primary">
|
||||||
|
Sent to the Board for review
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pb-3 space-y-2">
|
||||||
|
<p className="text-sm font-medium">{taskTitle}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{rootSubtaskCount} task{rootSubtaskCount === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{waveCount} wave{waveCount === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
ID: {taskId.slice(0, 8)}…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The Product Owner and Head of Marketing are reviewing this MegaTask.
|
||||||
|
Nothing is dispatched yet. Once they finish, open the umbrella task
|
||||||
|
and use <span className="font-medium">Approve & Start</span> to
|
||||||
|
release the sequenced tasks. You can leave and come back.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="gap-2 pt-0">
|
||||||
|
<Button variant="outline" size="sm" asChild className="flex-1">
|
||||||
|
<Link
|
||||||
|
href={`/tasks/${taskId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
View umbrella task
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="flex-1" onClick={onStartAnother}>
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Start another
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ function draftToText(draft: DraftProposal): string {
|
|||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (draft.the_work?.length) {
|
if (Array.isArray(draft.the_work) && draft.the_work.length) {
|
||||||
lines.push("## The Work");
|
lines.push("## The Work");
|
||||||
for (const cell of draft.the_work) {
|
for (const cell of draft.the_work) {
|
||||||
lines.push(`### ${cellLabel(cell.team)}`, cell.summary);
|
lines.push(`### ${cellLabel(cell.team)}`, cell.summary);
|
||||||
@@ -73,7 +73,7 @@ export function DraftProposalCard({
|
|||||||
isLaunching = false,
|
isLaunching = false,
|
||||||
}: DraftProposalCardProps) {
|
}: DraftProposalCardProps) {
|
||||||
const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "Medium";
|
const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "Medium";
|
||||||
const cells = draft.the_work ?? [];
|
const cells = Array.isArray(draft.the_work) ? draft.the_work : [];
|
||||||
// Distinct cells only: the_work has one entry per work item, so a cell with
|
// Distinct cells only: the_work has one entry per work item, so a cell with
|
||||||
// several items would otherwise show its badge repeated (Backend Backend …).
|
// several items would otherwise show its badge repeated (Backend Backend …).
|
||||||
const distinctTeams = Array.from(new Set(cells.map((c) => c.team)));
|
const distinctTeams = Array.from(new Set(cells.map((c) => c.team)));
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export { ChatComposer } from "./chat-composer";
|
|||||||
export { DraftProposalCard } from "./draft-proposal-card";
|
export { DraftProposalCard } from "./draft-proposal-card";
|
||||||
export { BatchReviewCard } from "./batch-review-card";
|
export { BatchReviewCard } from "./batch-review-card";
|
||||||
export { SuccessCard } from "./success-card";
|
export { SuccessCard } from "./success-card";
|
||||||
|
export { BoardReviewSentCard } from "./board-review-sent-card";
|
||||||
export { IntakeForm } from "./intake-form";
|
export { IntakeForm } from "./intake-form";
|
||||||
|
|||||||
@@ -544,9 +544,9 @@ export function TaskTable({
|
|||||||
)}
|
)}
|
||||||
onClick={handleRowClick}
|
onClick={handleRowClick}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell className="max-w-[22rem]">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1 min-w-0"
|
||||||
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
|
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
|
||||||
>
|
>
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
@@ -569,8 +569,10 @@ export function TaskTable({
|
|||||||
href={"/tasks/" + task.id}
|
href={"/tasks/" + task.id}
|
||||||
className="block hover:underline min-w-0"
|
className="block hover:underline min-w-0"
|
||||||
>
|
>
|
||||||
<div className="font-medium flex items-center gap-2">
|
<div className="font-medium flex items-center gap-2 min-w-0">
|
||||||
<span className="truncate">{task.title}</span>
|
<span className="truncate" title={task.title}>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
{task.batch_id && !task.parent_task_id && (
|
{task.batch_id && !task.parent_task_id && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { CellWork, DraftProposal } from "@/lib/api/prompter";
|
||||||
|
import { Team } from "@/types";
|
||||||
|
import {
|
||||||
|
fillBatchProjects,
|
||||||
|
rebuildCellWork,
|
||||||
|
type BatchProposal,
|
||||||
|
} from "@/hooks/use-prompter";
|
||||||
|
|
||||||
|
const BE = "be-repo";
|
||||||
|
const FE = "fe-repo";
|
||||||
|
const BE2 = "be-core";
|
||||||
|
|
||||||
|
function proj(id: string, cell: Team) {
|
||||||
|
return { id, name: id, assigned_cell: cell };
|
||||||
|
}
|
||||||
|
|
||||||
|
function draft(partial: Partial<DraftProposal>): DraftProposal {
|
||||||
|
return {
|
||||||
|
title: "T",
|
||||||
|
description: "objective objective objective",
|
||||||
|
acceptance_criteria: ["a"],
|
||||||
|
team: Team.BACKEND,
|
||||||
|
...partial,
|
||||||
|
} as DraftProposal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mk(drafts: DraftProposal[]): BatchProposal {
|
||||||
|
return { title: "batch", drafts, dropped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("fillBatchProjects", () => {
|
||||||
|
it("fills a single-cell draft from the top-level project_id when it matches the cell", () => {
|
||||||
|
const batch = mk([
|
||||||
|
draft({ the_work: [{ team: Team.BACKEND, summary: "s", items: [] }] }),
|
||||||
|
]);
|
||||||
|
const out = fillBatchProjects(batch, [BE], [proj(BE, Team.BACKEND)]);
|
||||||
|
expect(out.drafts[0].the_work![0].project_id).toBe(BE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the single scoped repo for the cell when no project_id is set", () => {
|
||||||
|
const batch = mk([
|
||||||
|
draft({ the_work: [{ team: Team.FRONTEND, summary: "s", items: [] }] }),
|
||||||
|
]);
|
||||||
|
const out = fillBatchProjects(
|
||||||
|
batch,
|
||||||
|
[BE, FE],
|
||||||
|
[proj(BE, Team.BACKEND), proj(FE, Team.FRONTEND)],
|
||||||
|
);
|
||||||
|
expect(out.drafts[0].the_work![0].project_id).toBe(FE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a cell empty when 2+ scoped repos belong to that cell (real ambiguity)", () => {
|
||||||
|
const batch = mk([
|
||||||
|
draft({ the_work: [{ team: Team.BACKEND, summary: "s", items: [] }] }),
|
||||||
|
]);
|
||||||
|
const out = fillBatchProjects(
|
||||||
|
batch,
|
||||||
|
[BE, BE2],
|
||||||
|
[proj(BE, Team.BACKEND), proj(BE2, Team.BACKEND)],
|
||||||
|
);
|
||||||
|
expect(out.drafts[0].the_work![0].project_id).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite an explicit per-cell project_id", () => {
|
||||||
|
const batch = mk([
|
||||||
|
draft({
|
||||||
|
the_work: [
|
||||||
|
{ team: Team.BACKEND, summary: "s", items: [], project_id: BE2 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const out = fillBatchProjects(
|
||||||
|
batch,
|
||||||
|
[BE, BE2],
|
||||||
|
[proj(BE, Team.BACKEND), proj(BE2, Team.BACKEND)],
|
||||||
|
);
|
||||||
|
expect(out.drafts[0].the_work![0].project_id).toBe(BE2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not trust a top-level project_id whose cell mismatches the work entry", () => {
|
||||||
|
const batch = mk([
|
||||||
|
draft({
|
||||||
|
project_id: FE, // frontend repo, but the work says backend
|
||||||
|
the_work: [{ team: Team.BACKEND, summary: "s", items: [] }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const out = fillBatchProjects(
|
||||||
|
batch,
|
||||||
|
[FE, BE],
|
||||||
|
[proj(FE, Team.FRONTEND), proj(BE, Team.BACKEND)],
|
||||||
|
);
|
||||||
|
expect(out.drafts[0].the_work![0].project_id).toBe(BE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent (returns the same batch reference on a second pass)", () => {
|
||||||
|
const batch = mk([
|
||||||
|
draft({ the_work: [{ team: Team.BACKEND, summary: "s", items: [] }] }),
|
||||||
|
]);
|
||||||
|
const once = fillBatchProjects(batch, [BE], [proj(BE, Team.BACKEND)]);
|
||||||
|
const twice = fillBatchProjects(once, [BE], [proj(BE, Team.BACKEND)]);
|
||||||
|
expect(twice).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy draft (no the_work) keeps a scoped top-level project_id as-is", () => {
|
||||||
|
const batch = mk([draft({ the_work: [], project_id: BE })]);
|
||||||
|
const out = fillBatchProjects(batch, [BE], [proj(BE, Team.BACKEND)]);
|
||||||
|
expect(out).toBe(batch); // nothing to fill → same reference
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rebuildCellWork", () => {
|
||||||
|
// The multi-select review card: the human picks a SET of repos for a task
|
||||||
|
// (one repo per delivery cell). rebuildCellWork turns that set into the
|
||||||
|
// the_work[] cell map the backend stores (task_cell_projects is unique per
|
||||||
|
// (task, team) — one repo per cell).
|
||||||
|
|
||||||
|
const projects = [
|
||||||
|
proj(BE, Team.BACKEND),
|
||||||
|
proj(BE2, Team.BACKEND),
|
||||||
|
proj(FE, Team.FRONTEND),
|
||||||
|
proj("ux-repo", Team.UX_UI),
|
||||||
|
];
|
||||||
|
|
||||||
|
it("maps one selected repo per cell and clears the top-level project_id", () => {
|
||||||
|
const out = rebuildCellWork([BE, FE], projects, []);
|
||||||
|
expect(out.project_id).toBeNull();
|
||||||
|
const teams = out.the_work.map((w) => w.team);
|
||||||
|
expect(teams).toEqual([Team.BACKEND, Team.FRONTEND]);
|
||||||
|
expect(out.the_work[0].project_id).toBe(BE);
|
||||||
|
expect(out.the_work[1].project_id).toBe(FE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the first selected repo when two are picked for the same cell (one repo per cell)", () => {
|
||||||
|
const out = rebuildCellWork([BE, BE2], projects, []);
|
||||||
|
expect(out.the_work).toHaveLength(1);
|
||||||
|
expect(out.the_work[0].team).toBe(Team.BACKEND);
|
||||||
|
expect(out.the_work[0].project_id).toBe(BE); // first wins
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves an existing cell entry's summary/items when the cell stays selected", () => {
|
||||||
|
const current: CellWork[] = [
|
||||||
|
{
|
||||||
|
team: Team.BACKEND,
|
||||||
|
summary: "build API",
|
||||||
|
items: ["x", "y"],
|
||||||
|
project_id: BE2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const out = rebuildCellWork([BE], projects, current);
|
||||||
|
expect(out.the_work).toHaveLength(1);
|
||||||
|
expect(out.the_work[0].summary).toBe("build API");
|
||||||
|
expect(out.the_work[0].items).toEqual(["x", "y"]);
|
||||||
|
expect(out.the_work[0].project_id).toBe(BE); // repo swapped, summary kept
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a cell entry when its cell is no longer selected", () => {
|
||||||
|
const current: CellWork[] = [
|
||||||
|
{ team: Team.BACKEND, summary: "s", items: [] },
|
||||||
|
{ team: Team.FRONTEND, summary: "s", items: [] },
|
||||||
|
];
|
||||||
|
const out = rebuildCellWork([FE], projects, current);
|
||||||
|
expect(out.the_work.map((w) => w.team)).toEqual([Team.FRONTEND]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a minimal entry for a newly-selected cell not in the_work", () => {
|
||||||
|
const out = rebuildCellWork([FE, "ux-repo"], projects, [
|
||||||
|
{ team: Team.BACKEND, summary: "s", items: [] },
|
||||||
|
]);
|
||||||
|
const teams = out.the_work.map((w) => w.team);
|
||||||
|
expect(teams).toEqual([Team.FRONTEND, Team.UX_UI]); // backend dropped, fe+ux added
|
||||||
|
expect(out.the_work[0]).toMatchObject({
|
||||||
|
team: Team.FRONTEND,
|
||||||
|
summary: "",
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty selection empties the_work (the task then has no project → blocked)", () => {
|
||||||
|
const out = rebuildCellWork([], projects, [
|
||||||
|
{ team: Team.BACKEND, summary: "s", items: [] },
|
||||||
|
]);
|
||||||
|
expect(out.the_work).toEqual([]);
|
||||||
|
expect(out.project_id).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+205
-24
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/lib/api/prompter";
|
} from "@/lib/api/prompter";
|
||||||
import { getErrorMessage } from "@/lib/api/client";
|
import { getErrorMessage } from "@/lib/api/client";
|
||||||
import { tasksApi } from "@/lib/api/tasks";
|
import { tasksApi } from "@/lib/api/tasks";
|
||||||
|
import { useProjects } from "@/hooks/use-projects";
|
||||||
import { Team } from "@/types";
|
import { Team } from "@/types";
|
||||||
import type { TaskType, TaskNature, Complexity } from "@/types";
|
import type { TaskType, TaskNature, Complexity } from "@/types";
|
||||||
|
|
||||||
@@ -68,7 +69,8 @@ const CELL_TEAMS: Team[] = [Team.BACKEND, Team.FRONTEND, Team.UX_UI];
|
|||||||
* is a delivery cell and that carries a project_id. Empty for a legacy
|
* is a delivery cell and that carries a project_id. Empty for a legacy
|
||||||
* single-cell draft that uses a top-level project_id instead. */
|
* single-cell draft that uses a top-level project_id instead. */
|
||||||
function draftCellProjectIds(draft: DraftProposal): string[] {
|
function draftCellProjectIds(draft: DraftProposal): string[] {
|
||||||
return (draft.the_work ?? [])
|
const work = Array.isArray(draft.the_work) ? draft.the_work : [];
|
||||||
|
return work
|
||||||
.filter(
|
.filter(
|
||||||
(w): w is CellWork & { project_id: string } =>
|
(w): w is CellWork & { project_id: string } =>
|
||||||
!!w?.team &&
|
!!w?.team &&
|
||||||
@@ -173,6 +175,17 @@ function draftFromEvent(data: Record<string, unknown> | undefined): {
|
|||||||
return { draft: d as unknown as DraftProposal, scale };
|
return { draft: d as unknown as DraftProposal, scale };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Coerce a value into a `CellWork[]`: a bare object → one-element array, a
|
||||||
|
* non-array non-object → empty. The backend coerces `the_work` before it
|
||||||
|
* reaches SSE, but a stale localStorage payload or a malformed frame could
|
||||||
|
* still carry a non-array, and the batch card does `the_work.map(...)` — so
|
||||||
|
* normalize here rather than crash. */
|
||||||
|
function asCellWork(value: unknown): CellWork[] {
|
||||||
|
if (Array.isArray(value)) return value as CellWork[];
|
||||||
|
if (value && typeof value === "object") return [value as CellWork];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
/** Pull a MegaTask ({title, drafts[]}) out of a `batch` SSE event's payload. */
|
/** Pull a MegaTask ({title, drafts[]}) out of a `batch` SSE event's payload. */
|
||||||
function batchFromEvent(
|
function batchFromEvent(
|
||||||
data: Record<string, unknown> | undefined,
|
data: Record<string, unknown> | undefined,
|
||||||
@@ -180,10 +193,15 @@ function batchFromEvent(
|
|||||||
if (!data || typeof data !== "object") return null;
|
if (!data || typeof data !== "object") return null;
|
||||||
const raw = (data as Record<string, unknown>).drafts;
|
const raw = (data as Record<string, unknown>).drafts;
|
||||||
if (!Array.isArray(raw)) return null;
|
if (!Array.isArray(raw)) return null;
|
||||||
const drafts = raw.filter(
|
const drafts = raw
|
||||||
(x): x is DraftProposal =>
|
.filter(
|
||||||
!!x && typeof (x as DraftProposal).title === "string",
|
(x): x is Record<string, unknown> =>
|
||||||
);
|
!!x && typeof (x as DraftProposal).title === "string",
|
||||||
|
)
|
||||||
|
.map((d) => ({
|
||||||
|
...(d as unknown as DraftProposal),
|
||||||
|
the_work: asCellWork((d as Record<string, unknown>).the_work),
|
||||||
|
}));
|
||||||
if (drafts.length === 0) return null;
|
if (drafts.length === 0) return null;
|
||||||
const title = (data as Record<string, unknown>).title;
|
const title = (data as Record<string, unknown>).title;
|
||||||
// Prefer the backend's dropped count; else compute from what we filtered, so a
|
// Prefer the backend's dropped count; else compute from what we filtered, so a
|
||||||
@@ -196,6 +214,128 @@ function batchFromEvent(
|
|||||||
return { title: typeof title === "string" ? title : "", drafts, dropped };
|
return { title: typeof title === "string" ? title : "", drafts, dropped };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rebuild a draft's ``the_work`` from the set of projects the human selected
|
||||||
|
* for it (the multi-select review card: one task can span several repos, one
|
||||||
|
* repo per delivery cell — the backend's ``task_cell_projects`` is unique per
|
||||||
|
* ``(task, team)``). Existing cell entries keep their summary/items; a newly-
|
||||||
|
* selected cell gets a minimal entry; a deselected cell's entry is dropped
|
||||||
|
* (that cell no longer participates). The top-level ``project_id`` is cleared
|
||||||
|
* — a multi-repo task targets via its cell map, not a top-level repo. Pure, so
|
||||||
|
* ``setBatchDraftProjects`` can stay a thin setState wrapper and this is
|
||||||
|
* unit-tested directly. */
|
||||||
|
export function rebuildCellWork(
|
||||||
|
ids: string[],
|
||||||
|
allProjects: { id: string; assigned_cell?: Team | "" }[],
|
||||||
|
currentWork: CellWork[],
|
||||||
|
): { the_work: CellWork[]; project_id: null } {
|
||||||
|
const cellToPid = new Map<Team, string>();
|
||||||
|
for (const id of ids) {
|
||||||
|
const proj = allProjects.find((p) => p.id === id);
|
||||||
|
const cell = proj?.assigned_cell;
|
||||||
|
if (cell && !cellToPid.has(cell)) cellToPid.set(cell, id);
|
||||||
|
}
|
||||||
|
const covered = new Set<Team>();
|
||||||
|
// Update existing cell entries in place, preserve summary/items.
|
||||||
|
const updated = currentWork.map((w) => {
|
||||||
|
const team = w?.team;
|
||||||
|
if (team && cellToPid.has(team)) {
|
||||||
|
covered.add(team);
|
||||||
|
return { ...w, project_id: cellToPid.get(team)! };
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
});
|
||||||
|
// Append a minimal entry for a newly-selected cell not already in the_work.
|
||||||
|
const appended: CellWork[] = [];
|
||||||
|
for (const [team, pid] of cellToPid) {
|
||||||
|
if (!covered.has(team)) {
|
||||||
|
appended.push({ team, summary: "", items: [], project_id: pid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop entries for cells no longer selected.
|
||||||
|
const the_work = [...updated, ...appended].filter((w) => {
|
||||||
|
const team = w?.team;
|
||||||
|
return team && cellToPid.has(team);
|
||||||
|
});
|
||||||
|
return { the_work, project_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill each draft's missing project assignment from the MegaTask's scoped
|
||||||
|
* repos, so the review card is pre-filled instead of forcing the human to
|
||||||
|
* re-pick the projects they already scoped at intake. The intake prompt tells
|
||||||
|
* the agent to set a top-level ``project_id`` (each task lives in one repo),
|
||||||
|
* and the backend's scope validator falls back to it — so this mirrors that:
|
||||||
|
* an explicit per-cell ``the_work[].project_id`` wins; else the draft's
|
||||||
|
* top-level ``project_id`` (when scoped); else the single scoped repo that
|
||||||
|
* belongs to this cell, when unambiguous. An empty field stays empty (real
|
||||||
|
* ambiguity — 2+ scoped repos for the cell — the human picks). Returns the
|
||||||
|
* same batch reference when nothing changed (idempotent, no render loop). */
|
||||||
|
export function fillBatchProjects(
|
||||||
|
batch: BatchProposal,
|
||||||
|
projectIds: string[],
|
||||||
|
allProjects: { id: string; assigned_cell?: Team | "" }[],
|
||||||
|
): BatchProposal {
|
||||||
|
const scoped = new Set(projectIds);
|
||||||
|
const scopedProjects = allProjects.filter((p) => scoped.has(p.id));
|
||||||
|
const byCell = (team: Team) =>
|
||||||
|
scopedProjects.filter((p) => p.assigned_cell === team);
|
||||||
|
const isCell = (team: unknown): team is Team =>
|
||||||
|
typeof team === "string" &&
|
||||||
|
(CELL_TEAMS as readonly string[]).includes(team as Team);
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const drafts = batch.drafts.map((d) => {
|
||||||
|
const work = Array.isArray(d.the_work) ? d.the_work : [];
|
||||||
|
const cellWork = work.filter((w) => isCell(w?.team));
|
||||||
|
|
||||||
|
// Draft with at least one the_work cell entry: fill each cell's project_id.
|
||||||
|
if (cellWork.length > 0) {
|
||||||
|
let workChanged = false;
|
||||||
|
const newWork = work.map((w) => {
|
||||||
|
if (
|
||||||
|
!w ||
|
||||||
|
!isCell(w.team) ||
|
||||||
|
(w.project_id && scoped.has(w.project_id))
|
||||||
|
) {
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
// (1) the draft's top-level project_id (the agent's main assignment),
|
||||||
|
// but only when that repo actually belongs to this cell (a single-cell
|
||||||
|
// draft's repo should match its one cell; a mismatch is an agent error
|
||||||
|
// — fall through to auto-assign rather than land the wrong repo).
|
||||||
|
const top = d.project_id;
|
||||||
|
if (top && scoped.has(top)) {
|
||||||
|
const proj = scopedProjects.find((p) => p.id === top);
|
||||||
|
if (proj && proj.assigned_cell === w.team) {
|
||||||
|
workChanged = true;
|
||||||
|
return { ...w, project_id: top };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// (2) exactly one scoped repo belongs to this cell.
|
||||||
|
const matching = byCell(w.team);
|
||||||
|
if (matching.length === 1) {
|
||||||
|
workChanged = true;
|
||||||
|
return { ...w, project_id: matching[0].id };
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
});
|
||||||
|
if (!workChanged) return d;
|
||||||
|
changed = true;
|
||||||
|
return { ...d, the_work: newWork };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy draft (no cell the_work): one top-level project_id. Fill it only
|
||||||
|
// when exactly one scoped repo exists (truly unambiguous).
|
||||||
|
if (d.project_id && scoped.has(d.project_id)) return d;
|
||||||
|
if (scopedProjects.length === 1) {
|
||||||
|
changed = true;
|
||||||
|
return { ...d, project_id: scopedProjects[0].id };
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed ? { ...batch, drafts } : batch;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Refresh durability
|
// Refresh durability
|
||||||
//
|
//
|
||||||
@@ -271,6 +411,10 @@ export function usePrompter() {
|
|||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
// Every project (carries assigned_cell), for pre-filling a MegaTask batch's
|
||||||
|
// per-cell project_ids from the scoped repos (useProjects dedups with the
|
||||||
|
// batch card's own query by key).
|
||||||
|
const { data: allProjects = [] } = useProjects();
|
||||||
const [isLaunching, setIsLaunching] = useState(false);
|
const [isLaunching, setIsLaunching] = useState(false);
|
||||||
/** The latest tool the agent is using — "watch it work" status line. */
|
/** The latest tool the agent is using — "watch it work" status line. */
|
||||||
const [activity, setActivity] = useState<string | null>(null);
|
const [activity, setActivity] = useState<string | null>(null);
|
||||||
@@ -366,7 +510,15 @@ export function usePrompter() {
|
|||||||
if (evt.text) {
|
if (evt.text) {
|
||||||
setActivity(null); // first text clears the "preparing…" indicator
|
setActivity(null); // first text clears the "preparing…" indicator
|
||||||
appendDelta(evt.text);
|
appendDelta(evt.text);
|
||||||
setState("streaming");
|
// Trailing prose AFTER a draft/batch tool call (the agent keeps
|
||||||
|
// talking once the card is up) must still render in the bubble —
|
||||||
|
// but it must NOT clobber the preview state, or the card vanishes
|
||||||
|
// mid-turn and turn_end can't restore it (it only preserves these
|
||||||
|
// states if they're still set). Keep the card up; the delta lands
|
||||||
|
// in a fresh bubble (the batch/draft handler cleared streamingId).
|
||||||
|
setState((s) =>
|
||||||
|
s === "draft_preview" || s === "batch_preview" ? s : "streaming",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
@@ -508,6 +660,17 @@ export function usePrompter() {
|
|||||||
}
|
}
|
||||||
}, [sessionId, messages, state, editableDraft, batch, batchWaves]);
|
}, [sessionId, messages, state, editableDraft, batch, batchWaves]);
|
||||||
|
|
||||||
|
// Pre-fill each MegaTask draft's project from the scoped repos (the agent
|
||||||
|
// sets a top-level project_id; the backend falls back to it). Without this
|
||||||
|
// the review card shows empty Selects and blocks launch, forcing the human
|
||||||
|
// to re-pick the very projects they scoped at intake. Idempotent: once every
|
||||||
|
// cell has a project_id it returns the same batch reference (no loop).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!batch) return;
|
||||||
|
const filled = fillBatchProjects(batch, projectIds, allProjects);
|
||||||
|
if (filled !== batch) setBatch(filled);
|
||||||
|
}, [batch, projectIds, allProjects]);
|
||||||
|
|
||||||
// On mount, reconnect to a still-running session left behind by a reload.
|
// On mount, reconnect to a still-running session left behind by a reload.
|
||||||
const didRestoreRef = useRef(false);
|
const didRestoreRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -804,29 +967,33 @@ export function usePrompter() {
|
|||||||
// Confirm a MegaTask — create the umbrella + sequenced root-subtasks, reap
|
// Confirm a MegaTask — create the umbrella + sequenced root-subtasks, reap
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/** Reassign one cell of one task in the proposed MegaTask to a different
|
/** Set the projects one MegaTask task targets. The review card exposes a
|
||||||
* project. `entryIndex` is the the_work slot (the cell); pass -1 for a legacy
|
* multi-select checkbox list per task (one task can span several repos), so
|
||||||
* single-cell draft that has no per-cell map (sets the top-level project_id).
|
* the human picks the whole set at once instead of one dropdown per cell.
|
||||||
* Project does not affect the wave plan (waves derive from collision surface),
|
* A RoboCo project is per-cell, so the selection maps to one project per
|
||||||
* so the previewed waves stay valid. */
|
* cell in ``the_work[]`` (the backend's ``task_cell_projects`` is unique per
|
||||||
const updateBatchDraftProject = useCallback(
|
* ``(task, team)`` — one repo per cell). Existing entries keep their
|
||||||
(index: number, entryIndex: number, projectId: string) => {
|
* summary/items; a newly-selected cell gets a minimal entry; a deselected
|
||||||
|
* cell's entry is dropped (that cell no longer participates). The top-level
|
||||||
|
* ``project_id`` is cleared — a multi-repo task targets via its cell map.
|
||||||
|
* Project choice does not affect the wave plan (waves derive from collision
|
||||||
|
* surface), so the previewed waves stay valid. */
|
||||||
|
const setBatchDraftProjects = useCallback(
|
||||||
|
(index: number, ids: string[]) => {
|
||||||
setBatch((prev) => {
|
setBatch((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
drafts: prev.drafts.map((d, i) => {
|
drafts: prev.drafts.map((d, i) => {
|
||||||
if (i !== index) return d;
|
if (i !== index) return d;
|
||||||
if (entryIndex < 0) return { ...d, project_id: projectId };
|
const work = Array.isArray(d.the_work) ? d.the_work : [];
|
||||||
const the_work = (d.the_work ?? []).map((w, wi) =>
|
const rebuilt = rebuildCellWork(ids, allProjects, work);
|
||||||
wi === entryIndex ? { ...w, project_id: projectId } : w,
|
return { ...d, the_work: rebuilt.the_work, project_id: null };
|
||||||
);
|
|
||||||
return { ...d, the_work };
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[allProjects],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmBatch = useCallback(
|
const confirmBatch = useCallback(
|
||||||
@@ -891,10 +1058,24 @@ export function usePrompter() {
|
|||||||
setCreatedTaskId(result.umbrella_task_id);
|
setCreatedTaskId(result.umbrella_task_id);
|
||||||
setCreatedTaskTitle(batch.title.trim() || "MegaTask");
|
setCreatedTaskTitle(batch.title.trim() || "MegaTask");
|
||||||
setCreatedTaskTeam(route === "board" ? Team.BOARD : Team.MAIN_PM);
|
setCreatedTaskTeam(route === "board" ? Team.BOARD : Team.MAIN_PM);
|
||||||
toast.success(
|
if (route === "board") {
|
||||||
`MegaTask launched — ${result.root_subtask_ids.length} tasks in ` +
|
// Board route: the umbrella + root-subtasks are created HELD for the
|
||||||
`${result.waves.length} wave${result.waves.length === 1 ? "" : "s"}.`,
|
// PO + HoM to review — nothing is dispatched yet. The CEO releases the
|
||||||
);
|
// sequenced tasks with Approve & Start on the umbrella task once the
|
||||||
|
// board finishes (the existing CEO gate, not this chat). Say so, so
|
||||||
|
// "Board review & Start" doesn't read as "launched" the way the
|
||||||
|
// Main-PM route does.
|
||||||
|
toast.success(
|
||||||
|
"Sent to the Board for review — the Product Owner and Head of " +
|
||||||
|
"Marketing will review this MegaTask. Approve & Start it from the " +
|
||||||
|
"umbrella task once they're done.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
`MegaTask launched — ${result.root_subtask_ids.length} tasks in ` +
|
||||||
|
`${result.waves.length} wave${result.waves.length === 1 ? "" : "s"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
setState("success");
|
setState("success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Failed to launch MegaTask: ${getErrorMessage(err)}`);
|
toast.error(`Failed to launch MegaTask: ${getErrorMessage(err)}`);
|
||||||
@@ -978,7 +1159,7 @@ export function usePrompter() {
|
|||||||
batch,
|
batch,
|
||||||
batchWaves,
|
batchWaves,
|
||||||
batchResult,
|
batchResult,
|
||||||
updateBatchDraftProject,
|
setBatchDraftProjects,
|
||||||
confirmBatch,
|
confirmBatch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,19 +56,69 @@ class StreamChunk:
|
|||||||
data: dict[str, Any] = field(default_factory=dict)
|
data: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_to_list(value: Any) -> list[Any]:
|
||||||
|
"""Wrap a lone scalar/dict into a one-element list; drop junk to ``[]``.
|
||||||
|
|
||||||
|
Mirrors ``content.validators.coerce_to_list`` but always returns a list
|
||||||
|
(never ``None``) and never passes a non-list, non-scalar/dict through — the
|
||||||
|
panel treats these fields as arrays and would crash on anything else. A
|
||||||
|
bare string/dict is the well-intentioned single-item case (wrap it); a
|
||||||
|
number/bool/None is not a work unit, so it is dropped.
|
||||||
|
"""
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str | dict):
|
||||||
|
return [value]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# Fields that are lists of plain strings (vs ``the_work``, a list of work-unit
|
||||||
|
# dicts). The agent may emit these as XML-ish ``<item>…</item>`` elements, which
|
||||||
|
# the SDK parses into ``[{"item": {"$text": "…"}}, …]`` — coerce to flat str
|
||||||
|
# lists so they reach the panel and a VARCHAR[] column as strings, not dicts.
|
||||||
|
_STR_LIST_FIELDS = ("acceptance_criteria", "what_this_builds", "notes")
|
||||||
|
|
||||||
|
|
||||||
def _coerce_draft(data: Any) -> dict[str, Any] | None:
|
def _coerce_draft(data: Any) -> dict[str, Any] | None:
|
||||||
"""Return ``data`` as a draft dict (with a string ``title``), else ``None``.
|
"""Return ``data`` as a draft dict (with a string ``title``), else ``None``.
|
||||||
|
|
||||||
Accepts a dict, or a JSON string the agent may have passed.
|
Accepts a dict, or a JSON string the agent may have passed. Coerces the
|
||||||
|
list-shaped spec fields: ``the_work`` (a list of work-unit dicts) is wrapped
|
||||||
|
to a list, and each ``the_work`` entry's ``items`` plus the string-list
|
||||||
|
fields (``acceptance_criteria``, ``what_this_builds``, ``notes``) are
|
||||||
|
flattened to ``list[str]`` — the agent sometimes emits these as XML-ish
|
||||||
|
``<item>…</item>`` elements that the SDK parses into dict wrappers, which
|
||||||
|
would crash a ``VARCHAR[]`` insert and dump ``str(dict)`` into the rendered
|
||||||
|
description. The panel renders ``(draft.the_work ?? []).map(...)`` and
|
||||||
|
throws if ``the_work`` is a bare object, so this is the single choke point
|
||||||
|
that keeps a non-array from ever reaching SSE.
|
||||||
"""
|
"""
|
||||||
|
from roboco.foundation.policy.content.validators import coerce_str_list
|
||||||
|
|
||||||
if isinstance(data, str):
|
if isinstance(data, str):
|
||||||
try:
|
try:
|
||||||
data = json.loads(data)
|
data = json.loads(data)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
if isinstance(data, dict) and isinstance(data.get("title"), str):
|
if not (isinstance(data, dict) and isinstance(data.get("title"), str)):
|
||||||
return data
|
return None
|
||||||
return None
|
coerced = dict(data)
|
||||||
|
if "the_work" in coerced:
|
||||||
|
coerced["the_work"] = _coerce_to_list(coerced["the_work"])
|
||||||
|
for key in _STR_LIST_FIELDS:
|
||||||
|
if key in coerced:
|
||||||
|
coerced[key] = coerce_str_list(coerced[key])
|
||||||
|
work = coerced.get("the_work")
|
||||||
|
if isinstance(work, list):
|
||||||
|
coerced["the_work"] = [
|
||||||
|
{
|
||||||
|
**unit,
|
||||||
|
"items": coerce_str_list(unit.get("items")),
|
||||||
|
}
|
||||||
|
for unit in work
|
||||||
|
if isinstance(unit, dict)
|
||||||
|
]
|
||||||
|
return coerced
|
||||||
|
|
||||||
|
|
||||||
def _extract_draft(text: str) -> dict[str, Any] | None:
|
def _extract_draft(text: str) -> dict[str, Any] | None:
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ async def do_note(
|
|||||||
"next_steps": body.next_steps,
|
"next_steps": body.next_steps,
|
||||||
},
|
},
|
||||||
section=body.section,
|
section=body.section,
|
||||||
|
done=body.done,
|
||||||
|
next=body.next,
|
||||||
|
where_to_look=body.where_to_look,
|
||||||
)
|
)
|
||||||
return envelope_to_response(env, request)
|
return envelope_to_response(env, request)
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ class NoteRequest(BaseModel):
|
|||||||
# (auditor). Validated by the content model server-side. Omit it to write a
|
# (auditor). Validated by the content model server-side. Omit it to write a
|
||||||
# developer summary straight from ``text``.
|
# developer summary straight from ``text``.
|
||||||
section: dict[str, Any] | None = None
|
section: dict[str, Any] | None = None
|
||||||
|
# handoff scope — the PM/coordinator RESUMPTION fields, promoted to
|
||||||
|
# top-level typed strings so the tool schema declares them machine-visible.
|
||||||
|
# ``section: dict[str, Any]`` renders a schema with no visible sub-fields,
|
||||||
|
# so a weak model (minimax-m3) emits ``section={}`` and the resumption gate
|
||||||
|
# rejects ``done — Field required`` — the 2026-06-27 PM respawn-loop
|
||||||
|
# meltdown. The same model fills top-level ``string`` decision fields fine
|
||||||
|
# (proven live), so ``done``/``next`` follow that precedent (typed
|
||||||
|
# ``str = ""`` → schema declares ``string`` not ``anyOf[string, null]``).
|
||||||
|
# Filled into ``section`` server-side; ignored for non-handoff scopes.
|
||||||
|
done: str = ""
|
||||||
|
next: str = ""
|
||||||
|
where_to_look: list[str] | None = None
|
||||||
|
|
||||||
# List-typed fields tolerate a lone scalar: a single string (or, for
|
# List-typed fields tolerate a lone scalar: a single string (or, for
|
||||||
# ``options``, a single dict) is wrapped into a one-element list before
|
# ``options``, a single dict) is wrapped into a one-element list before
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
"""Request schemas for /api/v1/flow/* intent verbs."""
|
"""Request schemas for /api/v1/flow/* intent verbs."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Annotated, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, BeforeValidator, Field, field_validator
|
||||||
|
|
||||||
|
from roboco.foundation.policy.content.validators import coerce_str_list
|
||||||
|
|
||||||
|
# A ``list[str]`` field that tolerates the Claude SDK's XML-ish tool-input
|
||||||
|
# parsing: an LLM emitting a bullet list as ``<item>…</item>`` elements arrives
|
||||||
|
# nested (``[[["…"]]]`` / ``[{"item": {"$text": "…"}}, …]``), which a bare
|
||||||
|
# ``list[str]`` hard-rejects at validation time (the live ``i_will_plan`` crash:
|
||||||
|
# ``technical_considerations.1 Input should be a valid string``). The
|
||||||
|
# ``BeforeValidator`` flattens it to a flat ``list[str]`` first — same
|
||||||
|
# ``coerce_str_list`` used at the intake→DB boundary (Bug 3, MegaTask memory).
|
||||||
|
# Applied to EVERY LLM-authored list-of-strings on the flow surface so the SDK
|
||||||
|
# can't crash any of them (acceptance_criteria, issues, files, ac_verdicts,
|
||||||
|
# technical_considerations, covers_parent_criteria).
|
||||||
|
StrList = Annotated[list[str], BeforeValidator(coerce_str_list)]
|
||||||
|
|
||||||
|
|
||||||
class GiveMeWorkRequest(BaseModel):
|
class GiveMeWorkRequest(BaseModel):
|
||||||
@@ -28,7 +42,7 @@ class IWillWorkOnRequest(BaseModel):
|
|||||||
# so re-entry/recovery calls that omit them still pass route validation;
|
# so re-entry/recovery calls that omit them still pass route validation;
|
||||||
# depth + presence are enforced on FRESH dev claims by
|
# depth + presence are enforced on FRESH dev claims by
|
||||||
# choreographer._dev_plan_gate. The dev's `plan` doubles as the approach.
|
# choreographer._dev_plan_gate. The dev's `plan` doubles as the approach.
|
||||||
technical_considerations: list[str] = Field(default_factory=list)
|
technical_considerations: StrList = Field(default_factory=list)
|
||||||
risks: list[dict[str, str]] = Field(default_factory=list)
|
risks: list[dict[str, str]] = Field(default_factory=list)
|
||||||
open_questions: list[dict[str, str | bool]] = Field(default_factory=list)
|
open_questions: list[dict[str, str | bool]] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -111,7 +125,7 @@ class ClaimReviewRequest(BaseModel):
|
|||||||
class PassReviewRequest(BaseModel):
|
class PassReviewRequest(BaseModel):
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
notes: str = Field(..., min_length=1)
|
notes: str = Field(..., min_length=1)
|
||||||
ac_verdicts: list[str] | None = Field(
|
ac_verdicts: StrList | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description=(
|
description=(
|
||||||
"One verification entry per acceptance criterion (in criterion "
|
"One verification entry per acceptance criterion (in criterion "
|
||||||
@@ -123,7 +137,7 @@ class PassReviewRequest(BaseModel):
|
|||||||
|
|
||||||
class FailReviewRequest(BaseModel):
|
class FailReviewRequest(BaseModel):
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
issues: list[str] = Field(..., min_length=1)
|
issues: StrList = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class ClaimPrReviewRequest(BaseModel):
|
class ClaimPrReviewRequest(BaseModel):
|
||||||
@@ -162,7 +176,7 @@ class PrPassRequest(BaseModel):
|
|||||||
|
|
||||||
class PrFailRequest(BaseModel):
|
class PrFailRequest(BaseModel):
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
issues: list[str] = Field(..., min_length=1)
|
issues: StrList = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class ClaimDocTaskRequest(BaseModel):
|
class ClaimDocTaskRequest(BaseModel):
|
||||||
@@ -172,7 +186,7 @@ class ClaimDocTaskRequest(BaseModel):
|
|||||||
class IDocumentedRequest(BaseModel):
|
class IDocumentedRequest(BaseModel):
|
||||||
task_id: UUID
|
task_id: UUID
|
||||||
notes: str = Field(..., min_length=1)
|
notes: str = Field(..., min_length=1)
|
||||||
files: list[str] = Field(..., min_length=1)
|
files: StrList = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class TriageRequest(BaseModel):
|
class TriageRequest(BaseModel):
|
||||||
@@ -222,7 +236,7 @@ class IWillPlanRequest(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="List of {title, description} — server assigns id + order",
|
description="List of {title, description} — server assigns id + order",
|
||||||
)
|
)
|
||||||
technical_considerations: list[str] = Field(default_factory=list)
|
technical_considerations: StrList = Field(default_factory=list)
|
||||||
risks: list[dict[str, str]] = Field(default_factory=list)
|
risks: list[dict[str, str]] = Field(default_factory=list)
|
||||||
open_questions: list[dict[str, str | bool]] = Field(default_factory=list)
|
open_questions: list[dict[str, str | bool]] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -253,14 +267,14 @@ class DelegateRequest(BaseModel):
|
|||||||
estimated_complexity: str = Field(..., min_length=1)
|
estimated_complexity: str = Field(..., min_length=1)
|
||||||
# acceptance_criteria is required and non-empty; downstream policy
|
# acceptance_criteria is required and non-empty; downstream policy
|
||||||
# also denylist-checks each item against placeholder phrases.
|
# also denylist-checks each item against placeholder phrases.
|
||||||
acceptance_criteria: list[str] = Field(..., min_length=1)
|
acceptance_criteria: StrList = Field(..., min_length=1)
|
||||||
# Optional per-subtask project override. When omitted, the choreographer
|
# Optional per-subtask project override. When omitted, the choreographer
|
||||||
# resolves the project from the parent's Product map for this cell, then
|
# resolves the project from the parent's Product map for this cell, then
|
||||||
# falls back to the parent's project. Plain optional field — no validator.
|
# falls back to the parent's project. Plain optional field — no validator.
|
||||||
project_id: UUID | None = None
|
project_id: UUID | None = None
|
||||||
# Parent acceptance-criterion ids this subtask is responsible for. Lets the
|
# Parent acceptance-criterion ids this subtask is responsible for. Lets the
|
||||||
# coverage + roll-up AC gates verify every parent AC is claimed and satisfied.
|
# coverage + roll-up AC gates verify every parent AC is claimed and satisfied.
|
||||||
covers_parent_criteria: list[str] | None = None
|
covers_parent_criteria: StrList | None = None
|
||||||
|
|
||||||
# Pre-gateway parity: cross-field validators that catch the most common
|
# Pre-gateway parity: cross-field validators that catch the most common
|
||||||
# LLM-vs-schema confusions. Pre-gateway lived in
|
# LLM-vs-schema confusions. Pre-gateway lived in
|
||||||
|
|||||||
@@ -101,3 +101,55 @@ def coerce_to_list(value: Any) -> Any:
|
|||||||
if isinstance(value, str | dict):
|
if isinstance(value, str | dict):
|
||||||
return [value]
|
return [value]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Keys an LLM's tool input may wrap a piece of text under. The Claude SDK parses
|
||||||
|
# XML-ish mixed content the model sometimes emits for a list-of-strings field
|
||||||
|
# (e.g. ``<item>…</item>``) into ``{"item": {"$text": "…"}}``; ``$text`` is the
|
||||||
|
# SDK's marker for element text content. Ordered: most specific first.
|
||||||
|
_TEXT_KEYS: tuple[str, ...] = ("$text", "text", "item", "value", "content", "summary")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_strs(item: Any) -> list[str]:
|
||||||
|
"""Pull every string out of one list element, recursing through wrappers."""
|
||||||
|
if isinstance(item, str):
|
||||||
|
stripped = item.strip()
|
||||||
|
return [stripped] if stripped else []
|
||||||
|
if isinstance(item, dict):
|
||||||
|
for key in _TEXT_KEYS:
|
||||||
|
if key in item:
|
||||||
|
return _extract_strs(item[key])
|
||||||
|
# No recognized key: keep any bare string values the dict carries.
|
||||||
|
return [
|
||||||
|
str(v).strip() for v in item.values() if isinstance(v, str) and v.strip()
|
||||||
|
]
|
||||||
|
if isinstance(item, list):
|
||||||
|
out: list[str] = []
|
||||||
|
for sub in item:
|
||||||
|
out.extend(_extract_strs(sub))
|
||||||
|
return out
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_str_list(value: Any) -> list[str]:
|
||||||
|
"""Coerce a list-of-strings field to a flat ``list[str]``.
|
||||||
|
|
||||||
|
An LLM may emit a ``list[str]`` field (``acceptance_criteria``, ``notes``,
|
||||||
|
``what_this_builds``, a work unit's ``items``) as XML-ish ``<item>…</item>``
|
||||||
|
elements, which the Claude SDK parses into ``[{"item": {"$text": "…"}}, …]``.
|
||||||
|
A bare dict/scalar is the single-item case. This recurses through the
|
||||||
|
wrappers and returns only strings — never a dict — so the value is safe for a
|
||||||
|
``VARCHAR[]`` column (asyncpg rejects non-str elements with a DataError) and
|
||||||
|
for markdown rendering (``str(dict)`` would otherwise dump ``"{'item': …}"``).
|
||||||
|
Anything that cannot be reduced to a string is dropped.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str | dict):
|
||||||
|
value = [value]
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
for item in value:
|
||||||
|
out.extend(_extract_strs(item))
|
||||||
|
return out
|
||||||
|
|||||||
+13
-3
@@ -196,6 +196,9 @@ def note(
|
|||||||
what_struggled: str = "",
|
what_struggled: str = "",
|
||||||
next_steps: list[str] | str | None = None,
|
next_steps: list[str] | str | None = None,
|
||||||
section: dict[str, Any] | None = None,
|
section: dict[str, Any] | None = None,
|
||||||
|
done: str = "",
|
||||||
|
next: str = "",
|
||||||
|
where_to_look: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Write a journal entry, or (scope='handoff') your note SECTION.
|
"""Write a journal entry, or (scope='handoff') your note SECTION.
|
||||||
|
|
||||||
@@ -220,9 +223,13 @@ def note(
|
|||||||
Other journal scopes (note / learning / struggle) just need ``text``.
|
Other journal scopes (note / learning / struggle) just need ``text``.
|
||||||
|
|
||||||
scope='handoff' writes your dedicated SECTION (dev_notes / quick_context /
|
scope='handoff' writes your dedicated SECTION (dev_notes / quick_context /
|
||||||
auditor_notes) instead of the journal: pass ``section={...}`` with the
|
auditor_notes) instead of the journal. For a PM/coordinator RESUMPTION
|
||||||
section's fields (PM/resumption needs done+next; auditor needs
|
section pass the TOP-LEVEL fields ``done`` (what's been done) and ``next``
|
||||||
summary+severity), or just ``text`` for a developer summary.
|
(the immediate next step) — these are the required fields and they show
|
||||||
|
up here as discrete string params; ``where_to_look`` is optional. (Do NOT
|
||||||
|
pass an empty ``section={}``; the ``section`` dict is the free-form path
|
||||||
|
for other content types — developer ``{summary, changes}``, auditor
|
||||||
|
``{summary, severity}``.) Or just ``text`` for a developer summary.
|
||||||
"""
|
"""
|
||||||
return _post(
|
return _post(
|
||||||
"/api/v1/do/note",
|
"/api/v1/do/note",
|
||||||
@@ -241,6 +248,9 @@ def note(
|
|||||||
"what_struggled": what_struggled,
|
"what_struggled": what_struggled,
|
||||||
"next_steps": next_steps,
|
"next_steps": next_steps,
|
||||||
"section": section,
|
"section": section,
|
||||||
|
"done": done,
|
||||||
|
"next": next,
|
||||||
|
"where_to_look": where_to_look,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,25 @@ import json
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import structlog
|
import structlog
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from pydantic import BeforeValidator
|
||||||
|
|
||||||
|
from roboco.foundation.policy.content.validators import coerce_str_list
|
||||||
|
|
||||||
|
# A ``list[str]`` field that tolerates the Claude SDK's XML-ish tool-input
|
||||||
|
# parsing: an LLM emitting a bullet list as ``<item>…</item>`` elements arrives
|
||||||
|
# as ``[[["…"]]]`` / ``[{"item": {"$text": "…"}}, …]`` — nested arrays / dicts,
|
||||||
|
# not strings. A bare ``list[str]`` annotation hard-rejects element 1 (a list,
|
||||||
|
# not a str) at the MCP validation layer BEFORE the verb body runs, surfacing as
|
||||||
|
# ``1 validation error for i_will_planArguments technical_considerations.1
|
||||||
|
# Input should be a valid string``. The ``BeforeValidator`` flattens it to a
|
||||||
|
# flat ``list[str]`` first (same ``coerce_str_list`` used at the intake→DB
|
||||||
|
# boundary — see Bug 3 in the MegaTask memory).
|
||||||
|
StrList = Annotated[list[str], BeforeValidator(coerce_str_list)]
|
||||||
|
|
||||||
ORCHESTRATOR_URL = os.environ.get(
|
ORCHESTRATOR_URL = os.environ.get(
|
||||||
"ROBOCO_ORCHESTRATOR_URL",
|
"ROBOCO_ORCHESTRATOR_URL",
|
||||||
@@ -214,7 +228,7 @@ def i_will_work_on(
|
|||||||
task_id: str,
|
task_id: str,
|
||||||
plan: str | None = None,
|
plan: str | None = None,
|
||||||
steps: list[dict[str, str]] | None = None,
|
steps: list[dict[str, str]] | None = None,
|
||||||
technical_considerations: list[str] | None = None,
|
technical_considerations: StrList | None = None,
|
||||||
risks: list[dict[str, str]] | None = None,
|
risks: list[dict[str, str]] | None = None,
|
||||||
open_questions: list[dict[str, str | bool]] | None = None,
|
open_questions: list[dict[str, str | bool]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -333,7 +347,7 @@ def claim_review(task_id: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def pass_review(
|
def pass_review(
|
||||||
task_id: str, notes: str, ac_verdicts: list[str] | None = None
|
task_id: str, notes: str, ac_verdicts: StrList | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""QA: accept the work. notes >= 80 chars; journal:learning required.
|
"""QA: accept the work. notes >= 80 chars; journal:learning required.
|
||||||
|
|
||||||
@@ -347,7 +361,7 @@ def pass_review(
|
|||||||
return _post(_role_path("pass"), payload)
|
return _post(_role_path("pass"), payload)
|
||||||
|
|
||||||
|
|
||||||
def fail_review(task_id: str, issues: list[str]) -> dict[str, Any]:
|
def fail_review(task_id: str, issues: StrList) -> dict[str, Any]:
|
||||||
"""QA: reject the work with issues. Each issue should be concrete and actionable."""
|
"""QA: reject the work with issues. Each issue should be concrete and actionable."""
|
||||||
return _post(_role_path("fail"), {"task_id": task_id, "issues": issues})
|
return _post(_role_path("fail"), {"task_id": task_id, "issues": issues})
|
||||||
|
|
||||||
@@ -401,7 +415,7 @@ def claim_doc_task(task_id: str) -> dict[str, Any]:
|
|||||||
return _post(_role_path("claim_doc_task"), {"task_id": task_id})
|
return _post(_role_path("claim_doc_task"), {"task_id": task_id})
|
||||||
|
|
||||||
|
|
||||||
def i_documented(task_id: str, notes: str, files: list[str]) -> dict[str, Any]:
|
def i_documented(task_id: str, notes: str, files: StrList) -> dict[str, Any]:
|
||||||
"""Doc: mark documentation complete. files=['<doc-path>', ...]."""
|
"""Doc: mark documentation complete. files=['<doc-path>', ...]."""
|
||||||
return _post(
|
return _post(
|
||||||
_role_path("i_documented"),
|
_role_path("i_documented"),
|
||||||
@@ -463,7 +477,7 @@ def i_will_plan(
|
|||||||
plan: str,
|
plan: str,
|
||||||
approach: str = "",
|
approach: str = "",
|
||||||
sub_tasks: list[dict[str, str]] | None = None,
|
sub_tasks: list[dict[str, str]] | None = None,
|
||||||
technical_considerations: list[str] | None = None,
|
technical_considerations: StrList | None = None,
|
||||||
risks: list[dict[str, str]] | None = None,
|
risks: list[dict[str, str]] | None = None,
|
||||||
open_questions: list[dict[str, str | bool]] | None = None,
|
open_questions: list[dict[str, str | bool]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -506,9 +520,9 @@ def delegate(
|
|||||||
team: str,
|
team: str,
|
||||||
task_type: str,
|
task_type: str,
|
||||||
nature: str,
|
nature: str,
|
||||||
acceptance_criteria: list[str],
|
acceptance_criteria: StrList,
|
||||||
estimated_complexity: str = "medium",
|
estimated_complexity: str = "medium",
|
||||||
covers_parent_criteria: list[str] | None = None,
|
covers_parent_criteria: StrList | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""PM: create a subtask of parent_task_id.
|
"""PM: create a subtask of parent_task_id.
|
||||||
|
|
||||||
@@ -566,7 +580,7 @@ def pr_pass(task_id: str, notes: str) -> dict[str, Any]:
|
|||||||
return _post(_role_path("pr_pass"), {"task_id": task_id, "notes": notes})
|
return _post(_role_path("pr_pass"), {"task_id": task_id, "notes": notes})
|
||||||
|
|
||||||
|
|
||||||
def pr_fail(task_id: str, issues: list[str]) -> dict[str, Any]:
|
def pr_fail(task_id: str, issues: StrList) -> dict[str, Any]:
|
||||||
"""PR reviewer: fail the assembled PR with concrete issues → needs_revision."""
|
"""PR reviewer: fail the assembled PR with concrete issues → needs_revision."""
|
||||||
return _post(_role_path("pr_fail"), {"task_id": task_id, "issues": issues})
|
return _post(_role_path("pr_fail"), {"task_id": task_id, "issues": issues})
|
||||||
|
|
||||||
|
|||||||
@@ -4129,40 +4129,60 @@ class AgentOrchestrator:
|
|||||||
async def _persist_respawn_record(
|
async def _persist_respawn_record(
|
||||||
self, agent_slug: str, task_id: str, record: dict[str, Any]
|
self, agent_slug: str, task_id: str, record: dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Write-through one PM-respawn counter row (delete-then-insert upsert).
|
"""Write-through one PM-respawn counter row (atomic upsert).
|
||||||
|
|
||||||
Best-effort, mirroring ``_persist_waiting_record``: a persistence failure
|
Best-effort, mirroring ``_persist_waiting_record``: a persistence failure
|
||||||
must never gate or un-gate a spawn, so any error is logged and swallowed.
|
must never gate or un-gate a spawn, so any error is logged and swallowed.
|
||||||
The counter stays authoritative in memory regardless.
|
The counter stays authoritative in memory regardless.
|
||||||
|
|
||||||
|
Unlike ``_persist_waiting_record`` (inline-awaited, one row per agent),
|
||||||
|
this is scheduled fire-and-forget per gate mutation, and a respawn loop
|
||||||
|
fires several persists for the same ``(agent_slug, task_id)`` in quick
|
||||||
|
succession. A delete-then-insert raced under that concurrency: two
|
||||||
|
transactions for the same key overlapped, the loser's INSERT hit
|
||||||
|
``pk_respawn_tracker`` UniqueViolation, the durable count stuck at the
|
||||||
|
first INSERT's value, and a restart re-burned the strike threshold — the
|
||||||
|
exact re-burn this feature was built to stop (2026-06-27 live meltdown).
|
||||||
|
The single ``ON CONFLICT DO UPDATE`` upsert is race-free: concurrent
|
||||||
|
upserts on the same key serialize at row level.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from uuid import UUID as _UUID
|
from uuid import UUID as _UUID
|
||||||
|
|
||||||
from sqlalchemy import delete
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from roboco.db.base import get_session_factory
|
from roboco.db.base import get_session_factory
|
||||||
from roboco.db.tables import RespawnTrackerTable
|
from roboco.db.tables import RespawnTrackerTable
|
||||||
|
|
||||||
tid = _UUID(task_id)
|
tid = _UUID(task_id)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
stmt = pg_insert(RespawnTrackerTable).values(
|
||||||
|
agent_slug=agent_slug,
|
||||||
|
task_id=tid,
|
||||||
|
count=int(record["count"]),
|
||||||
|
last_status=record.get("last_status"),
|
||||||
|
last_check=record["last_check"],
|
||||||
|
tracing_resets=int(record.get("tracing_resets", 0)),
|
||||||
|
notified=bool(record.get("notified", False)),
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=[
|
||||||
|
RespawnTrackerTable.agent_slug,
|
||||||
|
RespawnTrackerTable.task_id,
|
||||||
|
],
|
||||||
|
set_={
|
||||||
|
"count": stmt.excluded.count,
|
||||||
|
"last_status": stmt.excluded.last_status,
|
||||||
|
"last_check": stmt.excluded.last_check,
|
||||||
|
"tracing_resets": stmt.excluded.tracing_resets,
|
||||||
|
"notified": stmt.excluded.notified,
|
||||||
|
"updated_at": stmt.excluded.updated_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
session_factory = get_session_factory()
|
session_factory = get_session_factory()
|
||||||
async with session_factory() as db:
|
async with session_factory() as db:
|
||||||
await db.execute(
|
await db.execute(stmt)
|
||||||
delete(RespawnTrackerTable).where(
|
|
||||||
RespawnTrackerTable.agent_slug == agent_slug,
|
|
||||||
RespawnTrackerTable.task_id == tid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
db.add(
|
|
||||||
RespawnTrackerTable(
|
|
||||||
agent_slug=agent_slug,
|
|
||||||
task_id=tid,
|
|
||||||
count=int(record["count"]),
|
|
||||||
last_status=record.get("last_status"),
|
|
||||||
last_check=record["last_check"],
|
|
||||||
tracing_resets=int(record.get("tracing_resets", 0)),
|
|
||||||
notified=bool(record.get("notified", False)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -4541,6 +4541,7 @@ class Choreographer:
|
|||||||
have gotten from the upfront completeness check.
|
have gotten from the upfront completeness check.
|
||||||
"""
|
"""
|
||||||
from roboco.foundation.policy.task_completeness import TaskCompletenessError
|
from roboco.foundation.policy.task_completeness import TaskCompletenessError
|
||||||
|
from roboco.services.base import ValidationError
|
||||||
|
|
||||||
parent_task_id = parent.id
|
parent_task_id = parent.id
|
||||||
try:
|
try:
|
||||||
@@ -4563,6 +4564,24 @@ class Choreographer:
|
|||||||
task_id=parent_task_id,
|
task_id=parent_task_id,
|
||||||
verb="delegate",
|
verb="delegate",
|
||||||
)
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
# A user-input error from task creation (e.g. delegating past
|
||||||
|
# MAX_TASK_DEPTH — ``_validate_parent_depth`` raises this with a
|
||||||
|
# remediation message telling the PM to create a sibling instead of
|
||||||
|
# a further nested subtask). Without this translator it escaped
|
||||||
|
# uncaught as a 500 ExceptionGroup; the agent never saw the fix.
|
||||||
|
# ``invalid_state`` carries the message as the remediation so the
|
||||||
|
# PM gets a clean, actionable rejection it can act on.
|
||||||
|
return await self._emit_rejection(
|
||||||
|
Envelope.invalid_state(
|
||||||
|
message=exc.message,
|
||||||
|
remediate=exc.message,
|
||||||
|
context_briefing=briefing,
|
||||||
|
).with_introspection(task=parent, role=role_str),
|
||||||
|
agent_id=pm_agent_id,
|
||||||
|
task_id=parent_task_id,
|
||||||
|
verb="delegate",
|
||||||
|
)
|
||||||
await self._wire_ux_frontend_dependency(new_task, parent)
|
await self._wire_ux_frontend_dependency(new_task, parent)
|
||||||
hint = self._sizing_hint(inputs)
|
hint = self._sizing_hint(inputs)
|
||||||
if hint is not None:
|
if hint is not None:
|
||||||
|
|||||||
@@ -36,6 +36,36 @@ if TYPE_CHECKING:
|
|||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_resumption_fields(
|
||||||
|
section: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
done: str,
|
||||||
|
next: str,
|
||||||
|
where_to_look: list[str] | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Fold the top-level resumption fields into the handoff ``section``.
|
||||||
|
|
||||||
|
``section: dict[str, Any]`` renders a tool schema with no visible
|
||||||
|
sub-fields, so a weak model (minimax-m3) emits ``section={}`` and the
|
||||||
|
resumption gate rejects ``done — Field required`` (the 2026-06-27 PM
|
||||||
|
respawn-loop meltdown). The top-level ``done`` / ``next`` /
|
||||||
|
``where_to_look`` string fields are the LLM-facing contract — they show
|
||||||
|
up in the tool schema as discrete fields the same model fills fine. Here
|
||||||
|
they fill any keys the explicit ``section`` omits without overwriting
|
||||||
|
keys the agent already supplied, so a capable model passing ``section``
|
||||||
|
directly is unaffected. Returns ``None`` when nothing was supplied so the
|
||||||
|
downstream ``{'summary': text}`` fallback + gate remediation still fire.
|
||||||
|
"""
|
||||||
|
merged: dict[str, Any] = dict(section) if section else {}
|
||||||
|
if done and "done" not in merged:
|
||||||
|
merged["done"] = done
|
||||||
|
if next and "next" not in merged:
|
||||||
|
merged["next"] = next
|
||||||
|
if where_to_look and "where_to_look" not in merged:
|
||||||
|
merged["where_to_look"] = where_to_look
|
||||||
|
return merged or None
|
||||||
|
|
||||||
|
|
||||||
# Scope catalog is canonical in foundation.policy.journaling.
|
# Scope catalog is canonical in foundation.policy.journaling.
|
||||||
# Derived here as a string frozenset for the existing call sites that
|
# Derived here as a string frozenset for the existing call sites that
|
||||||
# compare strings rather than the Scope enum.
|
# compare strings rather than the Scope enum.
|
||||||
@@ -541,6 +571,9 @@ class ContentActions:
|
|||||||
task_id: UUID | None = None,
|
task_id: UUID | None = None,
|
||||||
structured: dict[str, Any] | None = None,
|
structured: dict[str, Any] | None = None,
|
||||||
section: dict[str, Any] | None = None,
|
section: dict[str, Any] | None = None,
|
||||||
|
done: str = "",
|
||||||
|
next: str = "",
|
||||||
|
where_to_look: list[str] | None = None,
|
||||||
) -> Envelope:
|
) -> Envelope:
|
||||||
"""Write a journal entry, or (scope='handoff') the role's note section.
|
"""Write a journal entry, or (scope='handoff') the role's note section.
|
||||||
|
|
||||||
@@ -554,6 +587,17 @@ class ContentActions:
|
|||||||
- decision: context, options[], chosen, rationale, consequences
|
- decision: context, options[], chosen, rationale, consequences
|
||||||
- reflect: what_done, what_learned, what_struggled, next_steps
|
- reflect: what_done, what_learned, what_struggled, next_steps
|
||||||
|
|
||||||
|
For ``scope='handoff'`` the resumption section (PM / coordinator
|
||||||
|
roles) can be authored two ways: the nested ``section`` dict, OR the
|
||||||
|
top-level ``done`` / ``next`` / ``where_to_look`` string fields. The
|
||||||
|
top-level path is the LLM-facing contract — ``section: dict[str, Any]``
|
||||||
|
renders a tool schema with no visible sub-fields, so a weak model
|
||||||
|
(minimax-m3) emits ``section={}`` and the resumption gate rejects
|
||||||
|
``done — Field required``; the top-level typed strings show up in the
|
||||||
|
tool schema as discrete fields the same model fills fine (proven on
|
||||||
|
the decision scope). Top-level fields fill any keys the explicit
|
||||||
|
``section`` omits without overwriting supplied ones.
|
||||||
|
|
||||||
The note is always recorded. List-typed fields tolerate a
|
The note is always recorded. List-typed fields tolerate a
|
||||||
lone scalar (wrapped into a one-element list) and missing decision/
|
lone scalar (wrapped into a one-element list) and missing decision/
|
||||||
reflect narrative fields default to a visible placeholder, so a
|
reflect narrative fields default to a visible placeholder, so a
|
||||||
@@ -571,7 +615,12 @@ class ContentActions:
|
|||||||
# a journal entry. Content quality is enforced by the content model
|
# a journal entry. Content quality is enforced by the content model
|
||||||
# (apply_structured_note), so skip the journal-text soup check.
|
# (apply_structured_note), so skip the journal-text soup check.
|
||||||
return await self._record_section_handoff(
|
return await self._record_section_handoff(
|
||||||
agent_id=agent_id, text=text, task_id=task_id, structured=section
|
agent_id=agent_id,
|
||||||
|
text=text,
|
||||||
|
task_id=task_id,
|
||||||
|
structured=_merge_resumption_fields(
|
||||||
|
section, done=done, next=next, where_to_look=where_to_look
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return await self._write_journal_note(
|
return await self._write_journal_note(
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ def hint_for_short_quick_context(*, min_chars: int, task_id: str) -> str:
|
|||||||
return (
|
return (
|
||||||
f"your quick_context section is empty or under {min_chars} chars. Before "
|
f"your quick_context section is empty or under {min_chars} chars. Before "
|
||||||
f"delegate, call note(scope='handoff', task_id='{task_id}', "
|
f"delegate, call note(scope='handoff', task_id='{task_id}', "
|
||||||
"section={'done': '<state so far>', 'next': '<what the cell should do>'})"
|
"done='<state so far>', next='<what the cell should do>') to leave a "
|
||||||
" to leave a resumption handoff, then retry."
|
"resumption handoff (pass done and next as top-level string args, not "
|
||||||
|
"nested in section), then retry."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from sqlalchemy import select
|
|||||||
from roboco.db.tables import AgentTable, TaskTable
|
from roboco.db.tables import AgentTable, TaskTable
|
||||||
from roboco.foundation.identity import CELL_TEAMS
|
from roboco.foundation.identity import CELL_TEAMS
|
||||||
from roboco.foundation.policy.batch import is_batch_umbrella
|
from roboco.foundation.policy.batch import is_batch_umbrella
|
||||||
|
from roboco.foundation.policy.content.validators import coerce_str_list
|
||||||
from roboco.foundation.policy.sequencing.models import DraftSurface, SequencePlan
|
from roboco.foundation.policy.sequencing.models import DraftSurface, SequencePlan
|
||||||
from roboco.models.base import (
|
from roboco.models.base import (
|
||||||
AgentRole,
|
AgentRole,
|
||||||
@@ -235,6 +236,27 @@ class PrompterService:
|
|||||||
message="This task draft is missing acceptance criteria.",
|
message="This task draft is missing acceptance criteria.",
|
||||||
field="acceptance_criteria",
|
field="acceptance_criteria",
|
||||||
)
|
)
|
||||||
|
# Flatten the string-list fields to list[str]. The agent sometimes emits
|
||||||
|
# these as XML-ish <item>…</item> elements the SDK parses into dict
|
||||||
|
# wrappers ({"item": {"$text": "…"}}); left as-is they crash the
|
||||||
|
# VARCHAR[] insert (asyncpg: "expected str, got dict") and dump str(dict)
|
||||||
|
# into the rendered description. Coerce here too — a draft can arrive
|
||||||
|
# via redraft/localStorage, not only the intake choke point.
|
||||||
|
draft_data["acceptance_criteria"] = coerce_str_list(
|
||||||
|
draft_data.get("acceptance_criteria")
|
||||||
|
)
|
||||||
|
draft_data["what_this_builds"] = coerce_str_list(
|
||||||
|
draft_data.get("what_this_builds")
|
||||||
|
)
|
||||||
|
draft_data["notes"] = coerce_str_list(draft_data.get("notes"))
|
||||||
|
for unit in draft_data.get("the_work") or []:
|
||||||
|
if isinstance(unit, dict):
|
||||||
|
unit["items"] = coerce_str_list(unit.get("items"))
|
||||||
|
if not draft_data["acceptance_criteria"]:
|
||||||
|
raise ValidationError(
|
||||||
|
message="This task draft is missing acceptance criteria.",
|
||||||
|
field="acceptance_criteria",
|
||||||
|
)
|
||||||
# Recompose the description from the (possibly edited) structured fields —
|
# Recompose the description from the (possibly edited) structured fields —
|
||||||
# the task always carries a freshly-composed, consistent description.
|
# the task always carries a freshly-composed, consistent description.
|
||||||
draft_data["description"] = compose_description(draft_data)
|
draft_data["description"] = compose_description(draft_data)
|
||||||
@@ -833,8 +855,12 @@ def derive_scale(the_work: list[Any]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _clean_list(value: Any) -> list[str]:
|
def _clean_list(value: Any) -> list[str]:
|
||||||
"""Trimmed, non-empty string items from a possibly-missing list field."""
|
"""Trimmed, non-empty string items from a possibly-missing list field.
|
||||||
return [str(i).strip() for i in (value or []) if str(i).strip()]
|
|
||||||
|
Uses :func:`coerce_str_list` so an LLM's dict-wrapped items (``{"$text": …}``
|
||||||
|
etc.) are extracted to text rather than dumped as ``str(dict)``.
|
||||||
|
"""
|
||||||
|
return coerce_str_list(value)
|
||||||
|
|
||||||
|
|
||||||
def _text(value: Any) -> str:
|
def _text(value: Any) -> str:
|
||||||
|
|||||||
+18
-9
@@ -713,10 +713,14 @@ class TaskService(BaseService):
|
|||||||
async def _validate_parent_depth(self, parent_task_id: UUID) -> None:
|
async def _validate_parent_depth(self, parent_task_id: UUID) -> None:
|
||||||
"""Enforce MAX_TASK_DEPTH at creation time.
|
"""Enforce MAX_TASK_DEPTH at creation time.
|
||||||
|
|
||||||
Walks up the parent chain counting ancestors. Raises ValueError if
|
Walks up the parent chain counting ancestors. Raises ValidationError
|
||||||
adding a child under this parent would exceed MAX_TASK_DEPTH.
|
(a ServiceError the API/gateway translate to a clean 400 / remediation
|
||||||
Previously this was only enforced at branch-name generation time,
|
envelope) if adding a child under this parent would exceed
|
||||||
so invalid hierarchies could be created and only fail later at claim.
|
MAX_TASK_DEPTH. Previously this raised a bare ValueError that escaped
|
||||||
|
uncaught as a 500 (the message told the agent to create a sibling, but
|
||||||
|
it never reached the agent as a handled error), and before that it was
|
||||||
|
only enforced at branch-name generation time, so invalid hierarchies
|
||||||
|
could be created and only fail later at claim.
|
||||||
"""
|
"""
|
||||||
from roboco.templates.git.constants import MAX_TASK_DEPTH
|
from roboco.templates.git.constants import MAX_TASK_DEPTH
|
||||||
|
|
||||||
@@ -726,19 +730,24 @@ class TaskService(BaseService):
|
|||||||
while current_id is not None:
|
while current_id is not None:
|
||||||
key = str(current_id)
|
key = str(current_id)
|
||||||
if key in visited:
|
if key in visited:
|
||||||
raise ValueError(
|
raise ValidationError(
|
||||||
f"Circular reference detected at {key} while validating depth"
|
f"Circular reference detected at {key} while validating depth",
|
||||||
|
field="parent_task_id",
|
||||||
)
|
)
|
||||||
visited.add(key)
|
visited.add(key)
|
||||||
parent = await self.get(current_id)
|
parent = await self.get(current_id)
|
||||||
if parent is None:
|
if parent is None:
|
||||||
raise ValueError(f"Parent task {current_id} not found")
|
raise ValidationError(
|
||||||
|
f"Parent task {current_id} not found",
|
||||||
|
field="parent_task_id",
|
||||||
|
)
|
||||||
depth += 1
|
depth += 1
|
||||||
if depth >= MAX_TASK_DEPTH:
|
if depth >= MAX_TASK_DEPTH:
|
||||||
raise ValueError(
|
raise ValidationError(
|
||||||
f"Task hierarchy would exceed MAX_TASK_DEPTH={MAX_TASK_DEPTH}. "
|
f"Task hierarchy would exceed MAX_TASK_DEPTH={MAX_TASK_DEPTH}. "
|
||||||
"Create this work as a sibling of the deepest task instead "
|
"Create this work as a sibling of the deepest task instead "
|
||||||
"of a further nested subtask."
|
"of a further nested subtask.",
|
||||||
|
field="parent_task_id",
|
||||||
)
|
)
|
||||||
parent_parent = parent.parent_task_id
|
parent_parent = parent.parent_task_id
|
||||||
current_id = UUID(str(parent_parent)) if parent_parent else None
|
current_id = UUID(str(parent_parent)) if parent_parent else None
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ Uses '--' separator for task hierarchy to avoid git ref conflicts.
|
|||||||
Git cannot have both 'foo' as a branch AND 'foo/bar' as another branch,
|
Git cannot have both 'foo' as a branch AND 'foo/bar' as another branch,
|
||||||
so we use '--' instead of '/' for the task hierarchy portion.
|
so we use '--' instead of '/' for the task hierarchy portion.
|
||||||
|
|
||||||
Max 3 levels deep (root → subtask → sub-subtask).
|
Max 4 levels deep — MegaTask adds an umbrella Main-PM layer on top of the
|
||||||
|
normal flow, so the full path is umbrella → root → cell → dev.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -39,7 +40,7 @@ async def build_branch_name(
|
|||||||
team: str,
|
team: str,
|
||||||
task_service: "TaskService",
|
task_service: "TaskService",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build branch name with ancestor path (max 3 levels, full UUIDs).
|
"""Build branch name with ancestor path (max 4 levels, full UUIDs).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
task_id: The task to create branch for
|
task_id: The task to create branch for
|
||||||
@@ -48,7 +49,9 @@ async def build_branch_name(
|
|||||||
task_service: TaskService instance for fetching task hierarchy
|
task_service: TaskService instance for fetching task hierarchy
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Branch name in format: {type}/{team}/{root}--{sub}--{subsub}
|
Branch name in format: {type}/{team}/{root}--{sub}--{subsub}--{subsubsub}
|
||||||
|
(MegaTask: umbrella--root--cell--dev; shorter hierarchies omit the
|
||||||
|
trailing segments).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
BranchNameError: If branch_type invalid, task not found, or hierarchy too deep
|
BranchNameError: If branch_type invalid, task not found, or hierarchy too deep
|
||||||
|
|||||||
@@ -33,8 +33,16 @@ COMMIT_TYPES: Final[frozenset[str]] = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Maximum task hierarchy depth (root → subtask → sub-subtask)
|
# Maximum task hierarchy depth. MegaTask adds one Main-PM layer on top of the
|
||||||
MAX_TASK_DEPTH: Final[int] = 3
|
# normal flow, so the full hierarchy is 4 layers: umbrella (Main PM, depth 0)
|
||||||
|
# → root-subtask (Main PM, depth 1) → cell task (cell PM, depth 2) → dev
|
||||||
|
# subtask (depth 3). This was sized at 3 for the pre-MegaTask 3-layer flow
|
||||||
|
# (root→cell→dev = depths 0,1,2) and never raised when MegaTask shipped, so
|
||||||
|
# cell-PM delegation of dev subtasks was rejected with MAX_TASK_DEPTH=3 and
|
||||||
|
# deadlocked the cell PM into a respawn loop (2026-06-27 live meltdown).
|
||||||
|
# The validator rejects a child whose depth would reach MAX_TASK_DEPTH, so 4
|
||||||
|
# permits the dev subtask at depth 3 while still capping a 5th layer.
|
||||||
|
MAX_TASK_DEPTH: Final[int] = 4
|
||||||
|
|
||||||
# Git branch name character limit
|
# Git branch name character limit
|
||||||
GIT_BRANCH_MAX_LENGTH: Final[int] = 255
|
GIT_BRANCH_MAX_LENGTH: Final[int] = 255
|
||||||
|
|||||||
@@ -174,3 +174,50 @@ async def test_get_root_task_id_too_deep_raises() -> None:
|
|||||||
)
|
)
|
||||||
with pytest.raises(BranchNameError, match="too deep"):
|
with pytest.raises(BranchNameError, match="too deep"):
|
||||||
await get_root_task_id(uuid4(), fake_svc)
|
await get_root_task_id(uuid4(), fake_svc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_branch_name_allows_four_level_megatask_hierarchy() -> None:
|
||||||
|
"""The MegaTask hierarchy is 4 layers — umbrella (Main PM, depth 0) →
|
||||||
|
root-subtask (Main PM, depth 1) → cell task (cell PM, depth 2) → dev
|
||||||
|
subtask (depth 3). MAX_TASK_DEPTH was sized for the 3-layer normal flow
|
||||||
|
(root→cell→dev = depths 0,1,2) and never raised when MegaTask added the
|
||||||
|
umbrella layer, so cell-PM delegation of dev subtasks was rejected with
|
||||||
|
MAX_TASK_DEPTH=3 and the cell PM deadlocked into a respawn loop
|
||||||
|
(2026-06-27 live meltdown — be-pm on task 9980d0a0). The cap must
|
||||||
|
accommodate the 4-level MegaTask: a dev subtask's branch name builds as a
|
||||||
|
4-segment path (umbrella--root--cell--dev), not raise 'too deep'."""
|
||||||
|
umbrella, root, cell, dev = uuid4(), uuid4(), uuid4(), uuid4()
|
||||||
|
tasks = {
|
||||||
|
dev: SimpleNamespace(id=dev, parent_task_id=cell),
|
||||||
|
cell: SimpleNamespace(id=cell, parent_task_id=root),
|
||||||
|
root: SimpleNamespace(id=root, parent_task_id=umbrella),
|
||||||
|
umbrella: SimpleNamespace(id=umbrella, parent_task_id=None),
|
||||||
|
}
|
||||||
|
fake_svc = AsyncMock()
|
||||||
|
fake_svc.get.side_effect = lambda task_id: tasks[task_id]
|
||||||
|
|
||||||
|
name = await build_branch_name(dev, "feature", "backend", fake_svc)
|
||||||
|
|
||||||
|
assert name == (
|
||||||
|
f"feature/backend/{str(umbrella)[:8]}--{str(root)[:8]}"
|
||||||
|
f"--{str(cell)[:8]}--{str(dev)[:8]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_root_task_id_returns_umbrella_for_four_level_hierarchy() -> None:
|
||||||
|
"""Companion to the branch-name test: the root of a 4-level MegaTask chain
|
||||||
|
is the umbrella, and it must be reachable (not truncated as 'too deep')
|
||||||
|
under the raised cap."""
|
||||||
|
umbrella, root, cell, dev = uuid4(), uuid4(), uuid4(), uuid4()
|
||||||
|
tasks = {
|
||||||
|
dev: SimpleNamespace(id=dev, parent_task_id=cell),
|
||||||
|
cell: SimpleNamespace(id=cell, parent_task_id=root),
|
||||||
|
root: SimpleNamespace(id=root, parent_task_id=umbrella),
|
||||||
|
umbrella: SimpleNamespace(id=umbrella, parent_task_id=None),
|
||||||
|
}
|
||||||
|
fake_svc = AsyncMock()
|
||||||
|
fake_svc.get.side_effect = lambda task_id: tasks[task_id]
|
||||||
|
|
||||||
|
assert await get_root_task_id(dev, fake_svc) == umbrella
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ async def test_validate_parent_depth_missing_parent_raises(
|
|||||||
task_setup: dict,
|
task_setup: dict,
|
||||||
) -> None:
|
) -> None:
|
||||||
svc = task_setup["svc"]
|
svc = task_setup["svc"]
|
||||||
with pytest.raises(ValueError, match="not found"):
|
with pytest.raises(ValidationError, match="not found"):
|
||||||
await svc._validate_parent_depth(uuid4())
|
await svc._validate_parent_depth(uuid4())
|
||||||
|
|
||||||
|
|
||||||
@@ -190,14 +190,14 @@ async def test_validate_parent_depth_circular_reference(
|
|||||||
task_setup: dict,
|
task_setup: dict,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A self-referential parent loop raises ValueError."""
|
"""A self-referential parent loop raises ValidationError."""
|
||||||
svc = task_setup["svc"]
|
svc = task_setup["svc"]
|
||||||
a = await svc.create(_req(task_setup))
|
a = await svc.create(_req(task_setup))
|
||||||
b = await svc.create(_req(task_setup, parent_task_id=a.id))
|
b = await svc.create(_req(task_setup, parent_task_id=a.id))
|
||||||
# Force circular: a.parent_task_id = b.id
|
# Force circular: a.parent_task_id = b.id
|
||||||
a.parent_task_id = b.id
|
a.parent_task_id = b.id
|
||||||
await db_session.flush()
|
await db_session.flush()
|
||||||
with pytest.raises(ValueError, match="Circular reference"):
|
with pytest.raises(ValidationError, match="Circular reference"):
|
||||||
await svc._validate_parent_depth(a.id)
|
await svc._validate_parent_depth(a.id)
|
||||||
|
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ async def test_validate_parent_depth_circular_reference(
|
|||||||
async def test_validate_parent_depth_exceeds_max(
|
async def test_validate_parent_depth_exceeds_max(
|
||||||
task_setup: dict,
|
task_setup: dict,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Adding a child past MAX_TASK_DEPTH raises ValueError."""
|
"""Adding a child past MAX_TASK_DEPTH raises ValidationError."""
|
||||||
svc = task_setup["svc"]
|
svc = task_setup["svc"]
|
||||||
# Build a chain of MAX_TASK_DEPTH+1 tasks
|
# Build a chain of MAX_TASK_DEPTH+1 tasks
|
||||||
parent = None
|
parent = None
|
||||||
@@ -215,7 +215,7 @@ async def test_validate_parent_depth_exceeds_max(
|
|||||||
)
|
)
|
||||||
parent = new
|
parent = new
|
||||||
assert parent is not None
|
assert parent is not None
|
||||||
with pytest.raises(ValueError, match="MAX_TASK_DEPTH"):
|
with pytest.raises(ValidationError, match="MAX_TASK_DEPTH"):
|
||||||
await svc._validate_parent_depth(parent.id)
|
await svc._validate_parent_depth(parent.id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,54 @@ def test_normalize_propose_batch_reports_dropped_count() -> None:
|
|||||||
assert [d["title"] for d in chunks[0].data["drafts"]] == ["Good"]
|
assert [d["title"] for d in chunks[0].data["drafts"]] == ["Good"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_propose_batch_coerces_the_work_scalar_to_list() -> None:
|
||||||
|
# The agent may emit `the_work` as a lone object (single-cell task) or its
|
||||||
|
# `items` as a lone string instead of a list. The panel treats `the_work`
|
||||||
|
# as an array (`(draft.the_work ?? []).map`) and crashes if it isn't — so
|
||||||
|
# the backend coerces these list-shaped fields before they reach SSE, the
|
||||||
|
# same graceful single-item wrap the content models apply at confirm time.
|
||||||
|
msg = AssistantMessage(
|
||||||
|
[
|
||||||
|
ToolUseBlock(
|
||||||
|
"propose_batch",
|
||||||
|
{
|
||||||
|
"drafts": [
|
||||||
|
{
|
||||||
|
"title": "Single-cell with bare the_work",
|
||||||
|
"acceptance_criteria": "one string not a list",
|
||||||
|
"the_work": {
|
||||||
|
"team": "backend",
|
||||||
|
"summary": "do the thing",
|
||||||
|
"items": "lone item string",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
chunks = normalize(msg)
|
||||||
|
assert [c.kind for c in chunks] == ["batch"]
|
||||||
|
draft = chunks[0].data["drafts"][0]
|
||||||
|
assert draft["acceptance_criteria"] == ["one string not a list"]
|
||||||
|
assert isinstance(draft["the_work"], list) and len(draft["the_work"]) == 1
|
||||||
|
unit = draft["the_work"][0]
|
||||||
|
assert unit["team"] == "backend"
|
||||||
|
assert unit["items"] == ["lone item string"]
|
||||||
|
# A non-list, non-dict the_work (e.g. a stray string) is dropped so the
|
||||||
|
# panel never sees a value it cannot .map over.
|
||||||
|
msg2 = AssistantMessage(
|
||||||
|
[
|
||||||
|
ToolUseBlock(
|
||||||
|
"propose_batch",
|
||||||
|
{"drafts": [{"title": "Bad work", "the_work": "not a list"}]},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
draft2 = normalize(msg2)[0].data["drafts"][0]
|
||||||
|
assert draft2["the_work"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_propose_draft_without_title_is_ignored() -> None:
|
def test_normalize_propose_draft_without_title_is_ignored() -> None:
|
||||||
msg = AssistantMessage(
|
msg = AssistantMessage(
|
||||||
[ToolUseBlock("propose_draft", {"draft": {"acceptance_criteria": []}})]
|
[ToolUseBlock("propose_draft", {"draft": {"acceptance_criteria": []}})]
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ from uuid import uuid4
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from roboco.api.schemas.v1.flow import DelegateRequest
|
from roboco.api.schemas.v1.flow import (
|
||||||
|
DelegateRequest,
|
||||||
|
IWillPlanRequest,
|
||||||
|
IWillWorkOnRequest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_delegate_request_requires_task_type() -> None:
|
def test_delegate_request_requires_task_type() -> None:
|
||||||
@@ -48,3 +52,85 @@ def test_delegate_request_accepts_explicit_task_type() -> None:
|
|||||||
acceptance_criteria=["returns 200"],
|
acceptance_criteria=["returns 200"],
|
||||||
)
|
)
|
||||||
assert req.task_type == "code"
|
assert req.task_type == "code"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# StrList — SDK-nested list-of-strings coercion (Bug A)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_will_plan_request_flattens_sdk_nested_technical_considerations() -> None:
|
||||||
|
"""The Claude SDK parses XML-ish ``<item>…</item>`` list-of-strings tool
|
||||||
|
input into nested arrays (``[[["…"]]]``). A bare ``list[str]`` field
|
||||||
|
hard-rejects element 1 (a list, not a str) at validation time — the live
|
||||||
|
``i_will_plan`` crash: ``technical_considerations.1 Input should be a
|
||||||
|
valid string``. The ``StrList`` BeforeValidator must flatten it to a flat
|
||||||
|
``list[str]`` so the verb body receives clean strings.
|
||||||
|
"""
|
||||||
|
req = IWillPlanRequest(
|
||||||
|
task_id=uuid4(),
|
||||||
|
plan="Plan narrative describing the approach in full sentences.",
|
||||||
|
approach=(
|
||||||
|
"Approach text long enough to clear the 150-character minimum "
|
||||||
|
"enforced on the plan's Approach field so the Plan tab is fully "
|
||||||
|
"populated for audit and tracing instead of rendering an empty view."
|
||||||
|
),
|
||||||
|
technical_considerations=[
|
||||||
|
[[["Empty state distinct from loaded state, coverage target 80%"]]],
|
||||||
|
[{"item": {"$text": "Use asyncpg prepared statements"}}],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert req.technical_considerations == [
|
||||||
|
"Empty state distinct from loaded state, coverage target 80%",
|
||||||
|
"Use asyncpg prepared statements",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_will_work_on_request_flattens_dict_wrapped_technical_considerations() -> (
|
||||||
|
None
|
||||||
|
):
|
||||||
|
"""Same coercion on the developer planning verb — a dict-wrapped string
|
||||||
|
(``{"item": {"$text": "…"}}``, the SDK's element-text marker) must reduce
|
||||||
|
to the bare string, not ``str(dict)``."""
|
||||||
|
req = IWillWorkOnRequest(
|
||||||
|
task_id=uuid4(),
|
||||||
|
technical_considerations=[{"item": {"$text": "Cache the lookup result"}}],
|
||||||
|
)
|
||||||
|
assert req.technical_considerations == ["Cache the lookup result"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delegate_request_flattens_sdk_nested_acceptance_criteria() -> None:
|
||||||
|
"""``delegate``'s ``acceptance_criteria`` is the same list-of-strings shape
|
||||||
|
the SDK can nest (this is the ``delegate``-verb analogue of the MegaTask
|
||||||
|
Bug 3 crash). The ``StrList`` field must flatten the nested input so the
|
||||||
|
VARCHAR[] insert downstream never sees a dict/list element."""
|
||||||
|
req = DelegateRequest(
|
||||||
|
parent_task_id=uuid4(),
|
||||||
|
title="t",
|
||||||
|
description="add the new endpoint plus tests",
|
||||||
|
assigned_to="be-dev-1",
|
||||||
|
team="backend",
|
||||||
|
task_type="code",
|
||||||
|
nature="technical",
|
||||||
|
estimated_complexity="medium",
|
||||||
|
acceptance_criteria=[
|
||||||
|
[[["returns 200 for valid input"]]],
|
||||||
|
[{"item": {"$text": "rejects malformed input with 400"}}],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert req.acceptance_criteria == [
|
||||||
|
"returns 200 for valid input",
|
||||||
|
"rejects malformed input with 400",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_strlist_drops_non_string_junk_instead_of_crashing() -> None:
|
||||||
|
"""Non-string junk (a bare int, a dict with no string values, whitespace)
|
||||||
|
is dropped — the field never raises on garbage the SDK might emit; only
|
||||||
|
real strings survive. An all-junk payload yields an empty list (the
|
||||||
|
delegate min_length=1 gate then rejects it cleanly, not a 500)."""
|
||||||
|
req = IWillWorkOnRequest(
|
||||||
|
task_id=uuid4(),
|
||||||
|
technical_considerations=[42, {"foo": 123}, [[" "]], "real note"],
|
||||||
|
)
|
||||||
|
assert req.technical_considerations == ["real note"]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ every whitespace token is a placeholder (``wip wip``, ``tbd / na``).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from roboco.foundation.policy.content.validators import reject_trivial
|
from roboco.foundation.policy.content.validators import coerce_str_list, reject_trivial
|
||||||
|
|
||||||
|
|
||||||
def test_returns_trimmed_value_when_substantive() -> None:
|
def test_returns_trimmed_value_when_substantive() -> None:
|
||||||
@@ -57,3 +57,47 @@ def test_rejects_all_filler_token_string(soup: str) -> None:
|
|||||||
)
|
)
|
||||||
def test_accepts_real_text_containing_a_filler_word(ok: str) -> None:
|
def test_accepts_real_text_containing_a_filler_word(ok: str) -> None:
|
||||||
assert reject_trivial(ok, field="reason", min_chars=3) == ok.strip()
|
assert reject_trivial(ok, field="reason", min_chars=3) == ok.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# coerce_str_list — flatten an LLM's dict-wrapped list-of-strings to list[str].
|
||||||
|
# The agent sometimes emits a list[str] field as XML-ish <item>…</item> elements
|
||||||
|
# the Claude SDK parses into {"item": {"$text": "…"}}; left as dicts they crash
|
||||||
|
# a VARCHAR[] insert ("expected str, got dict") and dump str(dict) into prose.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerce_str_list_passes_plain_strings_through() -> None:
|
||||||
|
assert coerce_str_list(["one", "two"]) == ["one", "two"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerce_str_list_extracts_sdk_item_text_wrapper() -> None:
|
||||||
|
# The exact shape from the live crash: [{"item": {"$text": "…"}}, ...].
|
||||||
|
assert coerce_str_list(
|
||||||
|
[{"item": {"$text": "UX-UI delivers a sketch"}}, {"item": "Spec lands first"}]
|
||||||
|
) == ["UX-UI delivers a sketch", "Spec lands first"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerce_str_list_wraps_a_bare_dict() -> None:
|
||||||
|
assert coerce_str_list({"$text": "only one"}) == ["only one"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerce_str_list_wraps_a_bare_string() -> None:
|
||||||
|
assert coerce_str_list("only one") == ["only one"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerce_str_list_drops_non_string_junk() -> None:
|
||||||
|
# A non-str, non-dict element is dropped — never passed to a VARCHAR[] column.
|
||||||
|
assert coerce_str_list(["keep", 7, None, {"text": "nested"}]) == [
|
||||||
|
"keep",
|
||||||
|
"nested",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerce_str_list_recurses_into_nested_lists() -> None:
|
||||||
|
assert coerce_str_list([["a", {"item": "b"}], "c"]) == ["a", "b", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_coerce_str_list_none_and_empty() -> None:
|
||||||
|
assert coerce_str_list(None) == []
|
||||||
|
assert coerce_str_list([]) == []
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from roboco.services.base import ValidationError
|
||||||
from roboco.services.gateway.choreographer import (
|
from roboco.services.gateway.choreographer import (
|
||||||
Choreographer,
|
Choreographer,
|
||||||
ChoreographerDeps,
|
ChoreographerDeps,
|
||||||
@@ -284,3 +285,45 @@ async def test_delegate_blocks_when_parent_quick_context_empty() -> None:
|
|||||||
assert body["error"] == "tracing_gap"
|
assert body["error"] == "tracing_gap"
|
||||||
assert "quick_context>=min" in body["missing"]
|
assert "quick_context>=min" in body["missing"]
|
||||||
task_svc.create_subtask.assert_not_awaited()
|
task_svc.create_subtask.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delegate_past_max_depth_returns_invalid_state_not_500() -> None:
|
||||||
|
"""Bug B: delegating past MAX_TASK_DEPTH raised a bare ValueError that
|
||||||
|
escaped uncaught as a 500 ExceptionGroup. ``_validate_parent_depth`` now
|
||||||
|
raises ``ValidationError`` (a ServiceError), and the choreographer
|
||||||
|
translates it to an ``invalid_state`` envelope whose remediate carries the
|
||||||
|
"create as a sibling" instruction — so the PM gets a clean, actionable
|
||||||
|
rejection instead of a crash, and the agent loop doesn't respawn-blind.
|
||||||
|
"""
|
||||||
|
pm_id = uuid4()
|
||||||
|
parent_id = uuid4()
|
||||||
|
parent = MagicMock(
|
||||||
|
id=parent_id,
|
||||||
|
project_id=uuid4(),
|
||||||
|
status="in_progress",
|
||||||
|
assigned_to=pm_id,
|
||||||
|
quick_context="Decomposition planned; cells implement their slice next.",
|
||||||
|
)
|
||||||
|
depth_msg = (
|
||||||
|
"Task hierarchy would exceed MAX_TASK_DEPTH=4. Create this work as a "
|
||||||
|
"sibling of the deepest task instead of a further nested subtask."
|
||||||
|
)
|
||||||
|
task_svc = AsyncMock()
|
||||||
|
task_svc.get.return_value = parent
|
||||||
|
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
|
||||||
|
task_svc.get_subtasks.return_value = []
|
||||||
|
# create_subtask -> create -> _validate_parent_depth raises ValidationError.
|
||||||
|
task_svc.create_subtask.side_effect = ValidationError(
|
||||||
|
depth_msg, field="parent_task_id"
|
||||||
|
)
|
||||||
|
deps = _make_deps(task=task_svc)
|
||||||
|
c = Choreographer(deps)
|
||||||
|
|
||||||
|
env = await c.delegate(pm_id, parent_id, _delegate_inputs())
|
||||||
|
body = env.as_dict()
|
||||||
|
assert body["error"] == "invalid_state"
|
||||||
|
assert "MAX_TASK_DEPTH" in body["message"]
|
||||||
|
# The remediation must reach the agent — the whole point of the fix.
|
||||||
|
assert "sibling" in body["remediate"]
|
||||||
|
task_svc.create_subtask.assert_awaited_once()
|
||||||
|
|||||||
@@ -130,6 +130,96 @@ async def test_handoff_validation_error_returns_remediation_not_422() -> None:
|
|||||||
assert "auditor" in body["remediate"]
|
assert "auditor" in body["remediate"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handoff_pm_resumption_from_top_level_done_next() -> None:
|
||||||
|
"""A PM authors the resumption section from TOP-LEVEL ``done``/``next``
|
||||||
|
fields, not the nested ``section`` dict.
|
||||||
|
|
||||||
|
Background (2026-06-27 live meltdown): minimax-m3:cloud running the PM
|
||||||
|
roles emitted ``section={}`` (an empty object) for the handoff note 3
|
||||||
|
times identically, ignoring the prose remediate. ``ResumptionNote`` requires
|
||||||
|
``done``+``next`` so the gate rejected ``done — Field required``, the
|
||||||
|
do-server circuit breaker tripped after 3-4 rejections, and the tracing
|
||||||
|
gate (which obligates a handoff note before ``delegate``) deadlocked the
|
||||||
|
PM into a respawn loop. Root cause was structural: ``section: dict[str,
|
||||||
|
Any]`` renders a tool schema with NO visible sub-fields, so a weak model
|
||||||
|
emits ``{}`` while the SAME model fills the top-level ``string`` decision
|
||||||
|
fields fine (proven live — those succeeded). The fix follows the
|
||||||
|
precedent already in ``NoteRequest`` (the decision fields typed ``str =
|
||||||
|
""`` so the schema declares ``string``): promote ``done``/``next`` to
|
||||||
|
top-level typed string params so the tool schema shows them
|
||||||
|
machine-visible. Passing ``done``+``next`` with no ``section`` must write
|
||||||
|
the resumption section successfully.
|
||||||
|
"""
|
||||||
|
agent_id, task_id = uuid4(), uuid4()
|
||||||
|
svc = _dev_task_svc(task_id, role="cell_pm")
|
||||||
|
ca = ContentActions(_make_deps(task=svc))
|
||||||
|
|
||||||
|
env = await ca.note(
|
||||||
|
agent_id=agent_id,
|
||||||
|
text="handoff",
|
||||||
|
scope="handoff",
|
||||||
|
done="Planned the decomposition into 3 cell tasks.",
|
||||||
|
next="Cells implement their slices in wave order.",
|
||||||
|
where_to_look=["panel/tasks — wave column", "the decomposition note"],
|
||||||
|
)
|
||||||
|
|
||||||
|
body = env.as_dict()
|
||||||
|
assert body["error"] is None
|
||||||
|
_tid, content_type, payload = svc.record_section_note.call_args.args
|
||||||
|
assert content_type == "resumption"
|
||||||
|
assert payload["done"] == "Planned the decomposition into 3 cell tasks."
|
||||||
|
assert payload["next"] == "Cells implement their slices in wave order."
|
||||||
|
assert payload["where_to_look"] == [
|
||||||
|
"panel/tasks — wave column",
|
||||||
|
"the decomposition note",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handoff_top_level_done_next_merge_into_explicit_section() -> None:
|
||||||
|
"""Top-level ``done``/``next`` fill any keys the explicit ``section`` omits
|
||||||
|
without overwriting keys the agent already supplied — backward compatible
|
||||||
|
with a capable model that passes ``section`` directly."""
|
||||||
|
agent_id, task_id = uuid4(), uuid4()
|
||||||
|
svc = _dev_task_svc(task_id, role="cell_pm")
|
||||||
|
ca = ContentActions(_make_deps(task=svc))
|
||||||
|
|
||||||
|
env = await ca.note(
|
||||||
|
agent_id=agent_id,
|
||||||
|
text="handoff",
|
||||||
|
scope="handoff",
|
||||||
|
section={"done": "Already-specified done."},
|
||||||
|
next="Top-level next fills the omitted next.",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert env.as_dict()["error"] is None
|
||||||
|
_tid, _ctype, payload = svc.record_section_note.call_args.args
|
||||||
|
assert payload["done"] == "Already-specified done."
|
||||||
|
assert payload["next"] == "Top-level next fills the omitted next."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handoff_empty_done_next_empty_section_still_remediates() -> None:
|
||||||
|
"""The backstop holds: with no top-level ``done``/``next`` AND an empty
|
||||||
|
``section``, the resumption gate still rejects with the remediation
|
||||||
|
envelope (never a raw 422) — so a model that ignores both paths still
|
||||||
|
gets the actionable message rather than tripping a silent crash."""
|
||||||
|
agent_id, task_id = uuid4(), uuid4()
|
||||||
|
svc = _dev_task_svc(task_id, role="cell_pm")
|
||||||
|
svc.record_section_note.side_effect = ContentValidationError(
|
||||||
|
"done", "field required"
|
||||||
|
)
|
||||||
|
ca = ContentActions(_make_deps(task=svc))
|
||||||
|
|
||||||
|
env = await ca.note(agent_id=agent_id, text="handoff", scope="handoff", section={})
|
||||||
|
body = env.as_dict()
|
||||||
|
|
||||||
|
assert body["error"] == "invalid_state"
|
||||||
|
assert "done" in body["message"]
|
||||||
|
assert "resumption" in body["remediate"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handoff_no_task_to_attach_is_rejected() -> None:
|
async def test_handoff_no_task_to_attach_is_rejected() -> None:
|
||||||
"""With no active/context task and no explicit task_id, handoff refuses."""
|
"""With no active/context task and no explicit task_id, handoff refuses."""
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
_SEEDED_COUNT = 3 # a persisted strike count, one below the trip threshold
|
_SEEDED_COUNT = 3 # a persisted strike count, one below the trip threshold
|
||||||
_STRIKE_COUNT = 2
|
_STRIKE_COUNT = 2
|
||||||
@@ -239,6 +240,47 @@ async def test_persist_record_swallows_db_failure() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_record_uses_atomic_upsert_no_delete_then_insert() -> None:
|
||||||
|
"""Wedge #3 (2026-06-27 live meltdown): the persist did delete-then-insert in
|
||||||
|
its own transaction. A respawn loop fires count 1->2->3->4 in quick
|
||||||
|
succession, scheduling a fire-and-forget persist per increment; two of those
|
||||||
|
for the same (agent_slug, task_id) race in separate transactions and the
|
||||||
|
loser's INSERT hit pk_respawn_tracker UniqueViolation, so the durable count
|
||||||
|
stuck at the first INSERT's value and a restart re-burned the strike
|
||||||
|
threshold — exactly the re-burn this feature was built to stop. The persist
|
||||||
|
must be a single atomic ON CONFLICT DO UPDATE upsert (no DELETE, no db.add):
|
||||||
|
concurrent upserts on the same key serialize at row level and never collide.
|
||||||
|
"""
|
||||||
|
orch = _new_orchestrator()
|
||||||
|
db = AsyncMock()
|
||||||
|
db.execute = AsyncMock(return_value=MagicMock())
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.__aenter__ = AsyncMock(return_value=db)
|
||||||
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
factory = MagicMock(return_value=ctx)
|
||||||
|
with patch("roboco.db.base.get_session_factory", return_value=factory):
|
||||||
|
await orch._persist_respawn_record(
|
||||||
|
"be-pm",
|
||||||
|
str(uuid4()),
|
||||||
|
{
|
||||||
|
"count": 4,
|
||||||
|
"last_status": "pending",
|
||||||
|
"last_check": datetime.now(UTC),
|
||||||
|
"tracing_resets": 0,
|
||||||
|
"notified": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# One atomic statement, no ORM add, no delete.
|
||||||
|
assert db.execute.await_count == 1
|
||||||
|
db.add.assert_not_called()
|
||||||
|
stmt = db.execute.await_args.args[0]
|
||||||
|
sql = str(stmt.compile(dialect=postgresql.dialect()))
|
||||||
|
assert "ON CONFLICT" in sql
|
||||||
|
assert "DO UPDATE" in sql
|
||||||
|
assert "DELETE" not in sql.upper()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Safety regression + transparency
|
# Safety regression + transparency
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user