Hotfixes (#293)
* fix(mcp): delegate tool carries the collision surface the B1a gate demands
TASK_AT_DELEGATE (5fc85419) requires intends_to_touch on code delegations,
but the MCP delegate tool never gained the parameter — PMs were rejected
with incomplete_input and could never comply (live fleet-wide delegation
wall, 2026-07-02). Adds intends_to_touch / adds_migration / touches_shared /
depends_on to the tool and forwards them; parity test locks the invariant.
* fix(git): assembly-integrity guard accepts squash-merged children
git cherry patch-matches each child commit individually, so a squash merge
(N patches -> one commit, new patch-id) read as 'work missing' and the #11
guard refused every legitimate submit_up (live 2026-07-02: S6 cell, three
squash-merged children at the branch tip). A parent commit carrying the
child's [taskid8] prefix now proves the child landed; children with no
marker stay flagged — the original incident the guard exists for.
* fix(git): diff head prefers origin when the local ref is behind it
Assembled branches advance on ORIGIN as child PRs squash-merge on GitHub,
but _resolve_head_ref preferred the inspecting clone's parked local ref —
the PR-gate reviewer's evidence diff was built from a pre-merge snapshot
and re-flagged work that had already landed (two false pr_fail verdicts
on the S6 cell PR, live 2026-07-02). When both refs exist and the local
ref is strictly behind origin, resolve to origin/<branch>; local-ahead
(unpushed) and diverged refs keep priority, single-ref cases unchanged.
* test(mcp): plan-gate fields must be tool parameters (parity class lock)
Extends the delegate parity test to every choreographer plan-depth gate:
a gate that can reject with missing=[field] must name only fields the
corresponding MCP tool can send, else the agent can never comply.
* perf(api): wire TaskSummaryResponse into a bounded /tasks/summary route
The panel fetched /api/tasks unbounded and full-fat — 2MB per refresh
measured live (2026-07-02), ~21KB/task, and the trimmed
TaskSummaryResponse was dead code. /tasks/summary returns exactly the
fields list views render (~50x lighter); the status-only branch of
/tasks now honors its limit, and the eleven unbounded task list routes
are capped.
* perf(panel): kill the per-page request flood and fat payloads
Every page load funneled ~85 default-prefetch RSC requests + 665KB of
images + the 2MB task list through the browser's six HTTP/1.1
connections — real data calls queued ~2s before being sent (measured
via Playwright resource timing, 2026-07-02).
- prefetch={false} on all 59 Links (sidebar, task rows, kanban cards,
list rows) — ~85 requests/refresh down to a handful
- icon/apple-icon/logo resized to render size: 665KB -> 54KB; unused
219KB PNG removed
- task list fetches the trimmed /tasks/summary (2MB -> ~100KB),
normalized into the Task shape so list consumers keep their types
- ReactQueryDevtools rendered only in development
* fix(api): Annotated limit defaults so direct-call tests get real ints
Query(...) positional defaults arrive as Query objects when a route
function is invoked outside the HTTP layer (integration tests call
handlers directly) and broke the new [:limit] slices.
* fix(api,panel): summary carries completed_at + board_review_complete
The metrics page computes velocity client-side from completed_at and the
CEO approval queue gates on board_review_complete — both were nulled by
the summary normalizer, so Completed Today/Week read 0 against 63 real
completions and approved-board tasks could vanish from the queue. The
queue also renders quick_context, so it fetches the full list (small,
status-scoped) via tasksApi.listFull instead of the summary.
* fix(runtime): spawn manifest workspace_path follows the task's project
_build_manifest_for_agent hardcoded the roboco project workspace for
every agent; a guard-core task's manifest claimed /data/workspaces/roboco
while the container cwd sat in the task worktree. The manifest now takes
the same _resolve_workspace_cwd the container -w uses — one resolver,
both surfaces agree by construction.
* fix(runtime): respawn breaker catches status ping-pong loops
Any status CHANGE fully reset the strike counter, so a blocked <->
in_progress oscillation — which changes status on every spawn while
advancing nothing — never tripped the gate (live 2026-07-02: 8 spawns
over two hours). A status never seen on the (agent, task) still fully
resets; a REVISITED status gets a bounded reset budget mirroring
tracing_resets, after which strikes accrue and the gate fires.
* fix(runtime): unassigned-QA dispatch spawns without pre-claiming
The transitioning pre-claim moved awaiting_qa -> claimed before the QA
agent existed; the spawned agent's claim_review/pass_review both demand
awaiting_qa, so it bounced twice and unclaimed (live 2026-07-02,
ba7b751c). Matches _spawn_assigned_qa and the external-PR reviewer
dispatch: no pre-claim, the agent claims itself via claim_review.
* fix(tests): narrow await_args before kwargs access (mypy union-attr)
* Minor upgrades
* fix(policy): team-match gate gains org-wide exemption; resume/unblock/activate now team-matched
needs_team_match sat in its permissive fallback since shipping (no
caller supplied Context.agent_team) and three PM verbs opted out
entirely — a misrouted frontend cell PM blocked, escalated, and held a
backend task through exactly that gap (live 2026-07-02). Org-wide roles
(main_pm, board, CEO, PR reviewer) are exempt so escalation handling
and root-PR gating keep working; cell-scoped roles are now enforced
wherever the caller supplies the team.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 7.3 KiB |
@@ -154,7 +154,7 @@ function SessionDetailContent() {
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href={backUrl}>
|
||||
<Link href={backUrl} prefetch={false}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Communications
|
||||
@@ -181,7 +181,7 @@ function SessionDetailContent() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={backUrl}>
|
||||
<Link href={backUrl} prefetch={false}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
@@ -235,6 +235,7 @@ function SessionDetailContent() {
|
||||
<ListTodo className="h-4 w-4 text-muted-foreground" />
|
||||
{primaryTask && (
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${primaryTask.task_id}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
|
||||
@@ -204,6 +204,7 @@ function SessionList({ channelId, groupId }: SessionListProps) {
|
||||
<div className="p-2 space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={session.id}
|
||||
href={`/communications/${session.id}?channel=${channelId}&group=${groupId}`}
|
||||
className="block p-3 rounded-lg border bg-card hover:bg-muted/50 hover:border-primary/50 transition-all"
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
|
||||
if (error || !entry) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/journals">
|
||||
<Link href="/journals" prefetch={false}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Journals
|
||||
@@ -81,7 +81,7 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
<Link href="/journals">
|
||||
<Link href="/journals" prefetch={false}>
|
||||
<Button>View All Journals</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/journals">
|
||||
<Link href="/journals" prefetch={false}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
@@ -143,7 +143,7 @@ export default function JournalEntryPage({ params }: JournalEntryPageProps) {
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span>Related Task</span>
|
||||
</div>
|
||||
<Link href={`/tasks/${entry.task_id}`}>
|
||||
<Link href={`/tasks/${entry.task_id}`} prefetch={false}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="hover:bg-muted cursor-pointer"
|
||||
|
||||
@@ -414,7 +414,7 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
if (error || !task) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href="/tasks">
|
||||
<Link href="/tasks" prefetch={false}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Tasks
|
||||
@@ -435,7 +435,7 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
<Link href="/tasks">
|
||||
<Link href="/tasks" prefetch={false}>
|
||||
<Button>View All Tasks</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -74,7 +74,7 @@ export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
|
||||
{isActive && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={"/agents/" + agent.id}>
|
||||
<Link href={"/agents/" + agent.id} prefetch={false}>
|
||||
<Activity className="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Link>
|
||||
|
||||
@@ -30,6 +30,7 @@ export function AgentStatusCards({ agent }: AgentStatusCardsProps) {
|
||||
<CardContent>
|
||||
{agent.task_id ? (
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={"/tasks/" + agent.task_id}
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
|
||||
@@ -36,7 +36,9 @@ export function WaitingAgentsAlert({ waitingAgents }: WaitingAgentsAlertProps) {
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={"/agents/" + agent.agent_id}>Resolve</Link>
|
||||
<Link href={"/agents/" + agent.agent_id} prefetch={false}>
|
||||
Resolve
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function FlaggedItem({
|
||||
{formatTime(flag.created_at)}
|
||||
</span>
|
||||
{flag.related_task_id && (
|
||||
<Link href={"/tasks/" + flag.related_task_id}>
|
||||
<Link href={"/tasks/" + flag.related_task_id} prefetch={false}>
|
||||
<span className="text-primary hover:underline">
|
||||
Task #{flag.related_task_id.slice(0, 8)}
|
||||
</span>
|
||||
@@ -88,7 +88,7 @@ export function FlaggedItem({
|
||||
{!isResolved && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{flag.related_task_id && (
|
||||
<Link href={"/tasks/" + flag.related_task_id}>
|
||||
<Link href={"/tasks/" + flag.related_task_id} prefetch={false}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function CommunicationsView() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/communications">
|
||||
<Link href="/communications" prefetch={false}>
|
||||
<Button variant="outline">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Full View
|
||||
@@ -80,6 +80,7 @@ export function CommunicationsView() {
|
||||
</Badge>
|
||||
</div>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/communications?channel=${selectedChannel.id}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
@@ -118,6 +119,7 @@ export function CommunicationsView() {
|
||||
Open the channel to view sessions and send messages
|
||||
</p>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/communications?channel=${selectedChannel.id}`}
|
||||
>
|
||||
<Button>View Sessions</Button>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function MessageItem({ message }: MessageItemProps) {
|
||||
)}
|
||||
{/* Related Task */}
|
||||
{message.task_id && (
|
||||
<Link href={"/tasks/" + message.task_id}>
|
||||
<Link href={"/tasks/" + message.task_id} prefetch={false}>
|
||||
<Badge variant="outline" className="text-xs mt-2 hover:bg-muted">
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
Task #{message.task_id.slice(0, 8)}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function ActiveBlockersPanel({
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{blockedTasks.map((task) => (
|
||||
<Link key={task.id} href={"/tasks/" + task.id}>
|
||||
<Link key={task.id} href={"/tasks/" + task.id} prefetch={false}>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 dark:border-red-900 dark:bg-red-950 dark:hover:bg-red-900 transition-colors">
|
||||
<span className="text-lg">\uD83D\uDD34</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -91,7 +91,7 @@ export function ActiveBlockersPanel({
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<Link href="/tasks?status=blocked">
|
||||
<Link href="/tasks?status=blocked" prefetch={false}>
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
View All Blocked
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
|
||||
@@ -98,7 +98,7 @@ export function AuditorAlertsPanel({
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<Link href="/auditor">
|
||||
<Link href="/auditor" prefetch={false}>
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
View All Flags
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
|
||||
@@ -65,7 +65,9 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
|
||||
const { data: startTasks } = useQuery({
|
||||
queryKey: ["tasks", "awaiting-approve-start"],
|
||||
queryFn: async () => {
|
||||
const pending = await tasksApi.list({ status: TaskStatus.PENDING });
|
||||
// Full-fat fetch: this card renders quick_context, which the trimmed
|
||||
// summary list deliberately omits. The PENDING set is small.
|
||||
const pending = await tasksApi.listFull({ status: TaskStatus.PENDING });
|
||||
return pending.filter(
|
||||
(t) => t.board_review_complete === true && t.team !== Team.MAIN_PM,
|
||||
);
|
||||
@@ -219,6 +221,7 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
|
||||
<Badge variant="outline">{task.team}</Badge>
|
||||
</div>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${task.id}`}
|
||||
className="font-medium hover:underline line-clamp-1"
|
||||
>
|
||||
@@ -231,7 +234,7 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
|
||||
<Link href={`/tasks/${task.id}`}>
|
||||
<Link href={`/tasks/${task.id}`} prefetch={false}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -348,6 +351,7 @@ export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${selectedTask.id}`}
|
||||
target="_blank"
|
||||
className="text-sm text-primary flex items-center gap-1 mt-2 hover:underline"
|
||||
|
||||
@@ -76,7 +76,7 @@ export function CommandCenter() {
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Link href="/settings">
|
||||
<Link href="/settings" prefetch={false}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@@ -166,6 +166,7 @@ export function PrReviewQueue({ className }: PrReviewQueueProps) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${task.id}`}
|
||||
className="font-medium hover:underline line-clamp-1"
|
||||
>
|
||||
@@ -202,7 +203,11 @@ export function PrReviewQueue({ className }: PrReviewQueueProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
|
||||
<Link href={`/tasks/${task.id}`} title="Review details">
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
title="Review details"
|
||||
prefetch={false}
|
||||
>
|
||||
<Button variant="ghost" size="sm">
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -17,42 +17,42 @@ export function QuickActionsBar() {
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<CreateTaskDialog />
|
||||
|
||||
<Link href="/agents">
|
||||
<Link href="/agents" prefetch={false}>
|
||||
<Button variant="outline">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Spawn Agent
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/prompter">
|
||||
<Link href="/prompter" prefetch={false}>
|
||||
<Button variant="outline">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Task Intake
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/business?tab=secretary">
|
||||
<Link href="/business?tab=secretary" prefetch={false}>
|
||||
<Button variant="outline">
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
Secretary
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/communications">
|
||||
<Link href="/communications" prefetch={false}>
|
||||
<Button variant="outline">
|
||||
<Megaphone className="h-4 w-4 mr-2" />
|
||||
Broadcast Message
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/journals">
|
||||
<Link href="/journals" prefetch={false}>
|
||||
<Button variant="outline">
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
View Journals
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/auditor">
|
||||
<Link href="/auditor" prefetch={false}>
|
||||
<Button variant="outline">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Auditor Report
|
||||
|
||||
@@ -47,7 +47,7 @@ export function RecentActivityFeed({
|
||||
</ScrollArea>
|
||||
)}
|
||||
<div className="mt-4 pt-3 border-t">
|
||||
<Link href="/notifications">
|
||||
<Link href="/notifications" prefetch={false}>
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
View Full Activity
|
||||
<ArrowRight className="h-4 w-4 ml-2" />
|
||||
|
||||
@@ -55,6 +55,7 @@ export function ScorecardOverviewPanel() {
|
||||
Performance
|
||||
</CardTitle>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href="/metrics?tab=scorecards"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
|
||||
>
|
||||
|
||||
@@ -25,7 +25,7 @@ function OnDemandAgentCard({
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className="block">
|
||||
<Link href={href} className="block" prefetch={false}>
|
||||
<div className="rounded-lg border bg-card p-4 hover:bg-accent/50 transition-colors h-full flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
@@ -24,7 +24,7 @@ function formatTime(timestamp: string): string {
|
||||
|
||||
export function EntryCard({ entry }: EntryCardProps) {
|
||||
return (
|
||||
<Link href={`/journals/${entry.id}`}>
|
||||
<Link href={`/journals/${entry.id}`} prefetch={false}>
|
||||
<Card className="hover:shadow-md transition-shadow cursor-pointer group">
|
||||
<CardContent className="pt-4">
|
||||
{/* Header */}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function KanbanCard({
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<Link href={"/tasks/" + task.id} className="block">
|
||||
<Link href={"/tasks/" + task.id} className="block" prefetch={false}>
|
||||
<p className="font-medium text-sm line-clamp-2 hover:underline break-words">
|
||||
<span
|
||||
className="font-mono text-muted-foreground"
|
||||
|
||||
@@ -41,6 +41,7 @@ export function MobileSidebar() {
|
||||
<SheetHeader className="h-16 justify-center border-b px-4 text-left">
|
||||
<SheetTitle asChild>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href="/overview"
|
||||
onClick={close}
|
||||
className="flex items-center gap-2"
|
||||
|
||||
@@ -81,6 +81,7 @@ export function SidebarNav({
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
@@ -114,6 +115,7 @@ export function SidebarFooter({
|
||||
<div className="space-y-1">
|
||||
{footerItems.map((item) => (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
@@ -146,7 +148,11 @@ export function Sidebar() {
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between border-b px-4">
|
||||
{!sidebarCollapsed && (
|
||||
<Link href="/overview" className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/overview"
|
||||
className="flex items-center gap-2"
|
||||
prefetch={false}
|
||||
>
|
||||
<Image
|
||||
src="/roboco-logo.png"
|
||||
alt="RoboCo"
|
||||
|
||||
@@ -79,7 +79,11 @@ export function NotificationBell() {
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<Link href="/notifications" onClick={() => setOpen(false)}>
|
||||
<Link
|
||||
href="/notifications"
|
||||
onClick={() => setOpen(false)}
|
||||
prefetch={false}
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
View All Notifications
|
||||
</Button>
|
||||
|
||||
@@ -73,6 +73,7 @@ export function BoardReviewSentCard({
|
||||
<CardFooter className="gap-2 pt-0">
|
||||
<Button variant="outline" size="sm" asChild className="flex-1">
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${taskId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -52,6 +52,7 @@ export function SuccessCard({
|
||||
<CardFooter className="gap-2 pt-0">
|
||||
<Button variant="outline" size="sm" asChild className="flex-1">
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${taskId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -46,7 +46,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
<AgentRosterSync />
|
||||
{children}
|
||||
<Toaster position="top-right" />
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -78,7 +78,10 @@ export function SubtasksList({ task }: SubtasksListProps) {
|
||||
{completionPercent}% complete
|
||||
</span>
|
||||
)}
|
||||
<Link href={`/tasks?parent=${task.id}&team=${task.team}`}>
|
||||
<Link
|
||||
href={`/tasks?parent=${task.id}&team=${task.team}`}
|
||||
prefetch={false}
|
||||
>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
@@ -118,6 +121,7 @@ export function SubtasksList({ task }: SubtasksListProps) {
|
||||
{/* Subtask list */}
|
||||
{subtasks.map((subtask) => (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={subtask.id}
|
||||
href={`/tasks/${subtask.id}`}
|
||||
className="block"
|
||||
|
||||
@@ -155,7 +155,11 @@ function DependencyList({
|
||||
className={`flex items-center gap-2 p-3 rounded-lg ${itemBorderClass} ${itemBgClass} transition-colors`}
|
||||
>
|
||||
<Link2 className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<Link href={`/tasks/${depId}`} className="flex-1">
|
||||
<Link
|
||||
href={`/tasks/${depId}`}
|
||||
className="flex-1"
|
||||
prefetch={false}
|
||||
>
|
||||
<span className="font-mono text-sm hover:underline">
|
||||
{depId.slice(0, 8)}...
|
||||
</span>
|
||||
@@ -406,6 +410,7 @@ export function TabDependencies({ task }: TabDependenciesProps) {
|
||||
>
|
||||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${task.parent_task_id}`}
|
||||
className="flex-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function TabOverview({ task }: TabOverviewProps) {
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Subtask of:</span>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${task.parent_task_id}`}
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
|
||||
@@ -76,7 +76,7 @@ function SessionCard({ session }: { session: TaskSessionLink }) {
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex justify-end">
|
||||
<Link href={`/communications/${session.session_id}`}>
|
||||
<Link href={`/communications/${session.session_id}`} prefetch={false}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View Session
|
||||
|
||||
@@ -460,7 +460,7 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) {
|
||||
title truncates, so a long title never pushes the controls or the
|
||||
Actions menu out of place. */}
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<Link href="/tasks">
|
||||
<Link href="/tasks" prefetch={false}>
|
||||
<Button variant="ghost" size="icon" className="shrink-0">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@@ -507,6 +507,7 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
</div>
|
||||
{task.project_id && project ? (
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/projects`}
|
||||
className="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
|
||||
@@ -231,7 +231,7 @@ export function WorkSessionCard({ taskId }: WorkSessionCardProps) {
|
||||
|
||||
{/* View Full Session Link */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Link href={`/work-sessions/${session.id}`}>
|
||||
<Link href={`/work-sessions/${session.id}`} prefetch={false}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View Details
|
||||
|
||||
@@ -566,6 +566,7 @@ export function TaskTable({
|
||||
<span className="w-5 shrink-0" />
|
||||
)}
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={"/tasks/" + task.id}
|
||||
className="block hover:underline min-w-0"
|
||||
>
|
||||
|
||||
@@ -103,6 +103,7 @@ export function WorkSessionTable({
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/work-sessions/${session.id}`}
|
||||
className="font-medium hover:underline font-mono text-sm"
|
||||
>
|
||||
@@ -112,6 +113,7 @@ export function WorkSessionTable({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/tasks/${session.task_id}`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
@@ -135,7 +137,7 @@ export function WorkSessionTable({
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/work-sessions/${session.id}`}>
|
||||
<Link href={`/work-sessions/${session.id}`} prefetch={false}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -31,8 +31,66 @@ export interface BoardReviewEntry {
|
||||
timestamp: string | null;
|
||||
}
|
||||
|
||||
// Wire shape of GET /tasks/summary (backend TaskSummaryResponse) — exactly
|
||||
// the fields list views render; everything fat stays on GET /tasks/{id}.
|
||||
interface TaskSummaryWire {
|
||||
id: string;
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
priority: number;
|
||||
team: Team;
|
||||
assigned_to: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
estimated_complexity: Complexity;
|
||||
nature: TaskNature;
|
||||
task_type: TaskType;
|
||||
sequence: number;
|
||||
parent_task_id: string | null;
|
||||
batch_id: string | null;
|
||||
project_id: string | null;
|
||||
product_id: string | null;
|
||||
branch_name: string | null;
|
||||
pr_number: number | null;
|
||||
pr_url: string | null;
|
||||
pr_created: boolean;
|
||||
docs_complete: boolean;
|
||||
completed_at: string | null;
|
||||
board_review_complete: boolean;
|
||||
description_snippet: string | null;
|
||||
}
|
||||
|
||||
// Normalize a summary into the Task shape so list consumers keep their
|
||||
// types. Defaulted fields are never rendered by list views (verified in the
|
||||
// 2026-07-02 audit); anything needing them must fetch the full task.
|
||||
const summaryToTask = (s: TaskSummaryWire): Task => ({
|
||||
...s,
|
||||
description: s.description_snippet ?? "",
|
||||
acceptance_criteria: [],
|
||||
created_by: "",
|
||||
dependency_ids: [],
|
||||
blocker_ids: [],
|
||||
claimed_at: null,
|
||||
started_at: null,
|
||||
target_date: null,
|
||||
pm_approvals: {},
|
||||
plan: null,
|
||||
checkpoints: [],
|
||||
progress_updates: [],
|
||||
commits: [],
|
||||
dev_notes: null,
|
||||
qa_notes: null,
|
||||
auditor_notes: null,
|
||||
quick_context: null,
|
||||
self_verified: false,
|
||||
qa_verified: null,
|
||||
sessions: [],
|
||||
});
|
||||
|
||||
export const tasksApi = {
|
||||
// List tasks with optional filters
|
||||
// List tasks with optional filters — served by the trimmed summary route
|
||||
// (~50x lighter than the full TaskResponse list that measured 2MB and made
|
||||
// every page slow, 2026-07-02). Detail views fetch the full task via get().
|
||||
list: async (filters?: TaskFilters): Promise<Task[]> => {
|
||||
if (isMockMode()) {
|
||||
let tasks = [...mockTasks];
|
||||
@@ -49,10 +107,31 @@ export const tasksApi = {
|
||||
if (filters?.status) params.append("status", filters.status);
|
||||
if (filters?.team) params.append("team", filters.team);
|
||||
if (filters?.limit) params.append("limit", String(filters.limit));
|
||||
if (filters?.offset) params.append("offset", String(filters.offset));
|
||||
|
||||
const url = "/tasks?" + params.toString();
|
||||
const { data } = await api.get<Task[]>(url);
|
||||
const url = "/tasks/summary?" + params.toString();
|
||||
const { data } = await api.get<TaskSummaryWire[]>(url);
|
||||
return data.map(summaryToTask);
|
||||
},
|
||||
|
||||
// Full-fat list for consumers that render fields beyond the summary
|
||||
// (the CEO approval queue shows quick_context). Hits the heavy /tasks
|
||||
// route — keep the filter narrow and the limit small.
|
||||
listFull: async (filters?: TaskFilters): Promise<Task[]> => {
|
||||
if (isMockMode()) {
|
||||
let tasks = [...mockTasks];
|
||||
if (filters?.status) {
|
||||
tasks = tasks.filter((t) => t.status === filters.status);
|
||||
}
|
||||
if (filters?.team) {
|
||||
tasks = tasks.filter((t) => t.team === filters.team);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.status) params.append("status", filters.status);
|
||||
if (filters?.team) params.append("team", filters.team);
|
||||
params.append("limit", String(filters?.limit ?? 100));
|
||||
const { data } = await api.get<Task[]>("/tasks?" + params.toString());
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
@@ -38,11 +38,13 @@ from roboco.api.schemas.tasks import (
|
||||
TaskCountResponse,
|
||||
TaskResponse,
|
||||
TaskSessionLinkResponse,
|
||||
TaskSummaryResponse,
|
||||
TaskUpdate,
|
||||
TeamTasksQuery,
|
||||
ValidTransitionsResponse,
|
||||
enrich_task_with_context,
|
||||
task_list_to_response,
|
||||
task_list_to_summary_response,
|
||||
task_to_response,
|
||||
transform_update_data,
|
||||
)
|
||||
@@ -619,22 +621,63 @@ async def list_tasks(
|
||||
elif effective_team:
|
||||
tasks = await service.list_by_team(effective_team, limit=limit)
|
||||
elif status:
|
||||
tasks = await service.list_by_status(status)
|
||||
# list_by_status has no limit param — slice so the status-only
|
||||
# branch can't return the whole table (it silently skipped the
|
||||
# declared limit until 2026-07-02).
|
||||
tasks = (await service.list_by_status(status))[:limit]
|
||||
else:
|
||||
tasks = await service.list_all(limit)
|
||||
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=list[TaskSummaryResponse])
|
||||
async def list_tasks_summary(
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
team: Team | None = None,
|
||||
status: TaskStatus | None = None,
|
||||
limit: Annotated[int, Query(ge=1, le=1000)] = 500,
|
||||
) -> list[TaskSummaryResponse]:
|
||||
"""List tasks as trimmed summaries for panel list views.
|
||||
|
||||
Same filters and view permissions as the full list, ~50x lighter per
|
||||
task: no description/plan/progress/commits/notes. The panel task tree
|
||||
needs the whole set at once, so the default limit is higher than the
|
||||
full route's.
|
||||
"""
|
||||
service = get_task_service(db)
|
||||
permissions = get_permission_service()
|
||||
|
||||
effective_team = team
|
||||
if not permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL):
|
||||
if agent.team:
|
||||
effective_team = agent.team
|
||||
else:
|
||||
return []
|
||||
|
||||
if effective_team and status:
|
||||
tasks = await service.list_by_team(effective_team, status, limit)
|
||||
elif effective_team:
|
||||
tasks = await service.list_by_team(effective_team, limit=limit)
|
||||
elif status:
|
||||
tasks = (await service.list_by_status(status))[:limit]
|
||||
else:
|
||||
tasks = await service.list_all(limit)
|
||||
|
||||
return task_list_to_summary_response(tasks)
|
||||
|
||||
|
||||
@router.get("/my", response_model=list[TaskResponse])
|
||||
async def get_my_tasks(
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
status: TaskStatus | None = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 200,
|
||||
) -> list[TaskResponse]:
|
||||
"""Get tasks assigned to the current agent."""
|
||||
service = get_task_service(db)
|
||||
tasks = await service.list_by_assignee(agent.agent_id, status)
|
||||
tasks = (await service.list_by_assignee(agent.agent_id, status))[:limit]
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
@@ -644,6 +687,7 @@ async def get_pending_tasks(
|
||||
agent: CurrentAgentContext,
|
||||
permissions: PermissionServiceDep,
|
||||
team: Team | None = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 200,
|
||||
) -> list[TaskResponse]:
|
||||
"""Get pending tasks available to claim."""
|
||||
service = get_task_service(db)
|
||||
@@ -652,7 +696,7 @@ async def get_pending_tasks(
|
||||
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
||||
effective_team = team if can_view_all else agent.team
|
||||
|
||||
tasks = await service.list_pending(effective_team)
|
||||
tasks = (await service.list_pending(effective_team))[:limit]
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
@@ -662,6 +706,7 @@ async def get_blocked_tasks(
|
||||
agent: CurrentAgentContext,
|
||||
permissions: PermissionServiceDep,
|
||||
team: Team | None = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 200,
|
||||
) -> list[TaskResponse]:
|
||||
"""Get blocked tasks."""
|
||||
service = get_task_service(db)
|
||||
@@ -670,7 +715,7 @@ async def get_blocked_tasks(
|
||||
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
||||
effective_team = team if can_view_all else agent.team
|
||||
|
||||
tasks = await service.list_blocked(effective_team)
|
||||
tasks = (await service.list_blocked(effective_team))[:limit]
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
@@ -680,6 +725,7 @@ async def get_awaiting_qa_tasks(
|
||||
agent: CurrentAgentContext,
|
||||
permissions: PermissionServiceDep,
|
||||
team: Team | None = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 200,
|
||||
) -> list[TaskResponse]:
|
||||
"""Get tasks awaiting QA review."""
|
||||
service = get_task_service(db)
|
||||
@@ -688,7 +734,7 @@ async def get_awaiting_qa_tasks(
|
||||
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
||||
effective_team = team if can_view_all else agent.team
|
||||
|
||||
tasks = await service.list_awaiting_qa(effective_team)
|
||||
tasks = (await service.list_awaiting_qa(effective_team))[:limit]
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
@@ -698,6 +744,7 @@ async def get_awaiting_docs_tasks(
|
||||
agent: CurrentAgentContext,
|
||||
permissions: PermissionServiceDep,
|
||||
team: Team | None = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 200,
|
||||
) -> list[TaskResponse]:
|
||||
"""Get tasks awaiting documentation."""
|
||||
service = get_task_service(db)
|
||||
@@ -706,7 +753,7 @@ async def get_awaiting_docs_tasks(
|
||||
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
||||
effective_team = team if can_view_all else agent.team
|
||||
|
||||
tasks = await service.list_awaiting_docs(effective_team)
|
||||
tasks = (await service.list_awaiting_docs(effective_team))[:limit]
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
@@ -783,6 +830,7 @@ async def get_awaiting_pm_review_tasks(
|
||||
agent: CurrentAgentContext,
|
||||
permissions: PermissionServiceDep,
|
||||
team: Team | None = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 200,
|
||||
) -> list[TaskResponse]:
|
||||
"""Get tasks awaiting PM review."""
|
||||
service = get_task_service(db)
|
||||
@@ -791,7 +839,7 @@ async def get_awaiting_pm_review_tasks(
|
||||
can_view_all = permissions.can_perform_task_action(agent, TaskAction.VIEW_ALL)
|
||||
effective_team = team if can_view_all else agent.team
|
||||
|
||||
tasks = await service.list_awaiting_pm_review(effective_team)
|
||||
tasks = (await service.list_awaiting_pm_review(effective_team))[:limit]
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
@@ -818,7 +866,7 @@ async def get_awaiting_ceo_approval_tasks(
|
||||
)
|
||||
|
||||
service = get_task_service(db)
|
||||
tasks = await service.list_awaiting_ceo_approval()
|
||||
tasks = (await service.list_awaiting_ceo_approval())[:200]
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
@@ -845,7 +893,7 @@ async def get_external_pr_reviews(
|
||||
detail="Only PMs and management can view the PR-review queue",
|
||||
)
|
||||
service = get_task_service(db)
|
||||
tasks = await service.list_external_pr_reviews()
|
||||
tasks = (await service.list_external_pr_reviews())[:200]
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
@@ -1138,7 +1186,7 @@ async def get_subtasks(
|
||||
) -> list[TaskResponse]:
|
||||
"""Get subtasks of a task."""
|
||||
service = get_task_service(db)
|
||||
tasks = await service.get_subtasks(task_id)
|
||||
tasks = (await service.get_subtasks(task_id))[:500]
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
@@ -1149,7 +1197,7 @@ async def get_descendants(
|
||||
) -> list[TaskResponse]:
|
||||
"""Get ALL descendants of a task (recursive - children, grandchildren, etc.)."""
|
||||
service = get_task_service(db)
|
||||
tasks = await service.get_all_descendants(task_id)
|
||||
tasks = (await service.get_all_descendants(task_id))[:500]
|
||||
return task_list_to_response(tasks)
|
||||
|
||||
|
||||
|
||||
@@ -382,7 +382,13 @@ class TaskResponse(BaseModel):
|
||||
|
||||
|
||||
class TaskSummaryResponse(BaseModel):
|
||||
"""Lightweight task response for list views."""
|
||||
"""Lightweight task response for list views.
|
||||
|
||||
Carries exactly what the panel's list surfaces render — the task tree
|
||||
(parent/sequence), kanban card (type/snippet), and git badge (pr/branch)
|
||||
— and none of the fat columns (description, plan, progress_updates,
|
||||
commits, notes). Full payloads stay on /tasks/{id}.
|
||||
"""
|
||||
|
||||
id: UUID
|
||||
title: str
|
||||
@@ -394,10 +400,67 @@ class TaskSummaryResponse(BaseModel):
|
||||
updated_at: datetime | None
|
||||
estimated_complexity: Complexity
|
||||
nature: TaskNature
|
||||
task_type: TaskType
|
||||
sequence: int
|
||||
parent_task_id: UUID | None = None
|
||||
batch_id: UUID | None = None
|
||||
project_id: UUID | None = None
|
||||
product_id: UUID | None = None
|
||||
branch_name: str | None = None
|
||||
pr_number: int | None = None
|
||||
pr_url: str | None = None
|
||||
pr_created: bool = False
|
||||
docs_complete: bool = False
|
||||
# Client-side velocity metrics filter on completion time; the CEO
|
||||
# approval queue gates its button on board_review_complete.
|
||||
completed_at: datetime | None = None
|
||||
board_review_complete: bool = False
|
||||
description_snippet: str | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
_SUMMARY_SNIPPET_LEN = 200
|
||||
|
||||
|
||||
def task_to_summary_response(task: "TaskTable") -> TaskSummaryResponse:
|
||||
"""Trimmed list-view conversion — no fat JSON columns serialized."""
|
||||
snippet = (task.description or "")[:_SUMMARY_SNIPPET_LEN] or None
|
||||
return TaskSummaryResponse(
|
||||
id=require_uuid(task.id),
|
||||
title=task.title,
|
||||
status=task.status,
|
||||
priority=task.priority,
|
||||
team=task.team,
|
||||
assigned_to=to_python_uuid(task.assigned_to),
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
estimated_complexity=task.estimated_complexity,
|
||||
nature=task.nature,
|
||||
task_type=task.task_type,
|
||||
sequence=task.sequence,
|
||||
parent_task_id=to_python_uuid(task.parent_task_id),
|
||||
batch_id=to_python_uuid(task.batch_id),
|
||||
project_id=to_python_uuid(task.project_id),
|
||||
product_id=to_python_uuid(task.product_id),
|
||||
branch_name=getattr(task, "branch_name", None),
|
||||
pr_number=getattr(task, "pr_number", None),
|
||||
pr_url=getattr(task, "pr_url", None),
|
||||
pr_created=task.pr_created,
|
||||
docs_complete=task.docs_complete,
|
||||
completed_at=task.completed_at,
|
||||
board_review_complete=task.board_review_complete,
|
||||
description_snippet=snippet,
|
||||
)
|
||||
|
||||
|
||||
def task_list_to_summary_response(
|
||||
tasks: list["TaskTable"],
|
||||
) -> list[TaskSummaryResponse]:
|
||||
"""Convert list of TaskTable to trimmed summaries."""
|
||||
return [task_to_summary_response(t) for t in tasks]
|
||||
|
||||
|
||||
class ProgressRequest(BaseModel):
|
||||
"""Request to add progress update.
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ class BudgetPolicy:
|
||||
# unblock journal-decision gate). After this many consecutive resets the
|
||||
# gap stops counting as progress and strikes accrue, so the loop gate fires.
|
||||
pm_respawn_max_tracing_resets: int = 3
|
||||
# A status CHANGE normally resets the unproductive counter (forward
|
||||
# progress). That reset is also bounded for REVISITED statuses: an
|
||||
# A<->B oscillation (blocked <-> in_progress) changes status on every
|
||||
# spawn yet advances nothing — 2026-07-02 a dev looped for two hours
|
||||
# (8 spawns) without ever tripping the gate. A status never seen before
|
||||
# on this (agent, task) still always fully resets.
|
||||
pm_respawn_max_revisit_resets: int = 2
|
||||
verb_retry_max_per_minute: int = 3 # default cap for verbs not in VERB_RETRY_LIMITS
|
||||
|
||||
|
||||
|
||||
@@ -429,7 +429,7 @@ _ATOMIC_ACTIONS: dict[str, ActionSpec] = {
|
||||
allowed_task_types=None,
|
||||
preconditions=(),
|
||||
self_review_block=False,
|
||||
needs_team_match=False,
|
||||
needs_team_match=True,
|
||||
),
|
||||
# claim's source_statuses is the UNION across all roles — see CLAIM_RULES
|
||||
# for per-role authority. Both tables are authoritative; a validator
|
||||
@@ -494,7 +494,7 @@ _ATOMIC_ACTIONS: dict[str, ActionSpec] = {
|
||||
allowed_task_types=None,
|
||||
preconditions=(),
|
||||
self_review_block=False,
|
||||
needs_team_match=False,
|
||||
needs_team_match=True,
|
||||
),
|
||||
"pause": ActionSpec(
|
||||
name="pause",
|
||||
@@ -514,7 +514,7 @@ _ATOMIC_ACTIONS: dict[str, ActionSpec] = {
|
||||
allowed_task_types=None,
|
||||
preconditions=(),
|
||||
self_review_block=False,
|
||||
needs_team_match=False,
|
||||
needs_team_match=True,
|
||||
),
|
||||
"submit_verification": ActionSpec(
|
||||
name="submit_verification",
|
||||
@@ -1767,7 +1767,7 @@ def can_invoke_action(
|
||||
# service layer, so a consumer trusting the spec gate alone let a backend
|
||||
# dev claim a frontend task). When the caller supplies the agent's team via
|
||||
# Context, enforce it here; absent, defer to the service layer.
|
||||
rejection = _check_team_match(spec_action, task, ctx)
|
||||
rejection = _check_team_match(spec_action, task, ctx, role)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
if action == "claim":
|
||||
@@ -1777,18 +1777,39 @@ def can_invoke_action(
|
||||
return Decision.allow()
|
||||
|
||||
|
||||
# Org-wide actors act across cells by design: the Main PM absorbs every
|
||||
# cell's escalations, the board PR reviewer gates root PRs on the main_pm
|
||||
# team, and CEO / board decisions are global. Cell-scoped roles (developer,
|
||||
# qa, documenter, cell_pm) are the ones a dispatch misroute can weaponize —
|
||||
# live 2026-07-02 a frontend cell PM blocked, escalated, and briefly held a
|
||||
# backend task through exactly this gap.
|
||||
_ORG_WIDE_ROLES: frozenset[Role] = frozenset(
|
||||
{
|
||||
Role.MAIN_PM,
|
||||
Role.CEO,
|
||||
Role.PRODUCT_OWNER,
|
||||
Role.HEAD_MARKETING,
|
||||
Role.AUDITOR,
|
||||
Role.PR_REVIEWER,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _check_team_match(
|
||||
spec_action: ActionSpec, task: Any, ctx: Context
|
||||
spec_action: ActionSpec, task: Any, ctx: Context, role: Role | None = None
|
||||
) -> Decision | None:
|
||||
"""Reject a cross-team action when the caller's team is known.
|
||||
|
||||
``needs_team_match`` was enforced only at the service layer, so a consumer
|
||||
trusting the spec gate alone let a backend dev claim a frontend task. When
|
||||
the caller supplies the agent's team via Context, enforce it here; absent,
|
||||
defer to the service layer (backward compatible).
|
||||
defer to the service layer (backward compatible). Org-wide roles
|
||||
(``_ORG_WIDE_ROLES``) are exempt.
|
||||
"""
|
||||
if not spec_action.needs_team_match:
|
||||
return None
|
||||
if role is not None and role in _ORG_WIDE_ROLES:
|
||||
return None
|
||||
agent_team = getattr(ctx, "agent_team", None)
|
||||
if agent_team is None:
|
||||
return None
|
||||
|
||||
@@ -816,6 +816,10 @@ def delegate(
|
||||
acceptance_criteria: StrList,
|
||||
estimated_complexity: str = "medium",
|
||||
covers_parent_criteria: StrList | None = None,
|
||||
intends_to_touch: StrList | None = None,
|
||||
adds_migration: bool = False,
|
||||
touches_shared: bool = False,
|
||||
depends_on: StrList | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""PM: create a subtask of parent_task_id.
|
||||
|
||||
@@ -835,6 +839,14 @@ def delegate(
|
||||
EVERY parent criterion is claimed by a subtask and satisfied before
|
||||
the parent rolls up — split the parent's criteria across subtasks so
|
||||
their union covers all of them.
|
||||
intends_to_touch: Collision surface — file paths/globs this subtask
|
||||
will modify. REQUIRED for task_type="code": the sibling collision
|
||||
DAG can only sequence what is declared.
|
||||
adds_migration: True if the subtask adds a DB migration (migration
|
||||
adders are chained serially).
|
||||
touches_shared: True if the subtask edits a shared surface.
|
||||
depends_on: Task UUIDs this subtask must wait for — wired verbatim as
|
||||
dependency edges (use for ordering the surface rules would miss).
|
||||
"""
|
||||
return _post(
|
||||
_role_path("delegate"),
|
||||
@@ -849,6 +861,10 @@ def delegate(
|
||||
"acceptance_criteria": acceptance_criteria,
|
||||
"estimated_complexity": estimated_complexity,
|
||||
"covers_parent_criteria": covers_parent_criteria,
|
||||
"intends_to_touch": intends_to_touch,
|
||||
"adds_migration": adds_migration,
|
||||
"touches_shared": touches_shared,
|
||||
"depends_on": depends_on,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -599,7 +599,9 @@ GATEWAY_ENABLED_ROLES: frozenset[str] = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def _build_manifest_for_agent(agent_id: str, model: str) -> Path | None:
|
||||
def _build_manifest_for_agent(
|
||||
agent_id: str, model: str, workspace_path: str | None = None
|
||||
) -> Path | None:
|
||||
"""Write a SpawnManifest for developer-role agents; return the host path.
|
||||
|
||||
Returns ``None`` for roles outside ``GATEWAY_ENABLED_ROLES`` so callers
|
||||
@@ -608,6 +610,12 @@ def _build_manifest_for_agent(agent_id: str, model: str) -> Path | None:
|
||||
Args:
|
||||
agent_id: Agent slug (e.g. ``be-dev-1``).
|
||||
model: Resolved model name passed to ``SpawnInputs.agent_model``.
|
||||
workspace_path: The task-resolved workspace (project clone or per-task
|
||||
worktree) — the SAME path the container ``-w`` uses. Without it
|
||||
the manifest falls back to the agent's roboco-project workspace,
|
||||
which is WRONG for any other project's task (live 2026-07-02:
|
||||
be-dev-2's manifest pointed at /data/workspaces/roboco while the
|
||||
task lived in guard-core-saas-backend).
|
||||
|
||||
Returns:
|
||||
Absolute host path to the written JSON file, or ``None``.
|
||||
@@ -631,14 +639,18 @@ def _build_manifest_for_agent(agent_id: str, model: str) -> Path | None:
|
||||
raw_uuid = AGENT_UUIDS.get(agent_id)
|
||||
agent_uuid = UUID(raw_uuid) if raw_uuid else __import__("uuid").uuid4()
|
||||
|
||||
workspace_path = Path(settings.workspaces_root) / "roboco" / team / agent_id
|
||||
resolved_workspace = (
|
||||
Path(workspace_path)
|
||||
if workspace_path
|
||||
else Path(settings.workspaces_root) / "roboco" / team / agent_id
|
||||
)
|
||||
|
||||
manifest = build_for_role(
|
||||
SpawnInputs(
|
||||
agent_id=agent_uuid,
|
||||
role=role,
|
||||
team=team,
|
||||
workspace_path=workspace_path,
|
||||
workspace_path=resolved_workspace,
|
||||
agent_model=model,
|
||||
)
|
||||
)
|
||||
@@ -2601,7 +2613,13 @@ class AgentOrchestrator:
|
||||
# Spawn manifest + gateway flag — developer role only in Phase 1.
|
||||
# _build_manifest_for_agent writes the JSON file to the host and
|
||||
# returns the path; other roles get None and the gateway flag stays off.
|
||||
manifest_host_path = _build_manifest_for_agent(config.agent_id, subagent_model)
|
||||
# workspace_path mirrors the container -w (same resolver) so the
|
||||
# manifest never claims a different directory than the shell.
|
||||
manifest_host_path = _build_manifest_for_agent(
|
||||
config.agent_id,
|
||||
subagent_model,
|
||||
workspace_path=AgentOrchestrator._resolve_workspace_cwd(config),
|
||||
)
|
||||
if manifest_host_path:
|
||||
cmd.extend(
|
||||
[
|
||||
@@ -2621,6 +2639,27 @@ class AgentOrchestrator:
|
||||
)
|
||||
_ROLES_WITH_CELL_WORKSPACE: ClassVar[frozenset[str]] = frozenset({"documenter"})
|
||||
|
||||
@staticmethod
|
||||
def _resolve_workspace_cwd(config: AgentConfig) -> str | None:
|
||||
"""The task-resolved workspace path for this spawn, or None.
|
||||
|
||||
Single source of truth consumed by BOTH the container ``-w`` and the
|
||||
spawn manifest's ``workspace_path`` — they must agree, or the agent's
|
||||
prompt claims one directory while its shell sits in another (live
|
||||
2026-07-02: manifest said the roboco workspace for a guard-core task).
|
||||
"""
|
||||
role = get_agent_role(config.agent_id) or "developer"
|
||||
team = get_agent_team(config.agent_id) or ""
|
||||
project = _resolve_project_slug_from_git_context(config.git_context)
|
||||
if role in AgentOrchestrator._ROLES_WITH_AGENT_WORKSPACE:
|
||||
# Per-task worktree when the task has a branch (F123), else the
|
||||
# clone root. _agent_cwd_path is the SAME formula the Edit/Write
|
||||
# allowlist is built from, so -w and the allowlist match exactly.
|
||||
return _agent_cwd_path(project, team, config.agent_id, config.git_context)
|
||||
if role in AgentOrchestrator._ROLES_WITH_CELL_WORKSPACE:
|
||||
return _cell_workspace_path(project, team)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _append_workspace_cwd(cmd: list[str], config: AgentConfig) -> None:
|
||||
"""Set the container -w to the agent or cell workspace by role."""
|
||||
@@ -2630,25 +2669,13 @@ class AgentOrchestrator:
|
||||
# the workspace clone. Without this, container WORKDIR (/app from the
|
||||
# Dockerfile) shadows the workspace and every file op fails.
|
||||
#
|
||||
# Mirror the workspace-path selection in _get_role_permissions exactly:
|
||||
# Workspace selection lives in _resolve_workspace_cwd:
|
||||
# - developer / product_owner / head_marketing: per-agent workspace
|
||||
# - documenter: cell workspace
|
||||
# - qa / cell_pm / main_pm / auditor: no write workspace → omit -w
|
||||
role = get_agent_role(config.agent_id) or "developer"
|
||||
team = get_agent_team(config.agent_id) or ""
|
||||
project = _resolve_project_slug_from_git_context(config.git_context)
|
||||
if role in AgentOrchestrator._ROLES_WITH_AGENT_WORKSPACE:
|
||||
# Per-task worktree when the task has a branch (F123), else the
|
||||
# clone root. _agent_cwd_path is the SAME formula the Edit/Write
|
||||
# allowlist is built from, so -w and the allowlist match exactly.
|
||||
cmd.extend(
|
||||
[
|
||||
"-w",
|
||||
_agent_cwd_path(project, team, config.agent_id, config.git_context),
|
||||
]
|
||||
)
|
||||
elif role in AgentOrchestrator._ROLES_WITH_CELL_WORKSPACE:
|
||||
cmd.extend(["-w", _cell_workspace_path(project, team)])
|
||||
workspace = AgentOrchestrator._resolve_workspace_cwd(config)
|
||||
if workspace is not None:
|
||||
cmd.extend(["-w", workspace])
|
||||
|
||||
@staticmethod
|
||||
def _append_agent_auth_env(cmd: list[str], config: AgentConfig) -> None:
|
||||
@@ -9791,6 +9818,58 @@ Start now: evidence(task_id="{task_id}")
|
||||
# Use foundation's default; keep the local name for back-compat.
|
||||
_PM_RESPAWN_MAX_UNPRODUCTIVE = _AGENT_LOOP_BUDGET.pm_respawn_max_unproductive
|
||||
_PM_RESPAWN_MAX_TRACING_RESETS = _AGENT_LOOP_BUDGET.pm_respawn_max_tracing_resets
|
||||
_PM_RESPAWN_MAX_REVISIT_RESETS = _AGENT_LOOP_BUDGET.pm_respawn_max_revisit_resets
|
||||
|
||||
def _respawn_status_change_resets(
|
||||
self,
|
||||
key: tuple[str, Any],
|
||||
record: dict[str, Any],
|
||||
current_status: Any,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
"""Handle a status CHANGE; True when it resets the strike counter.
|
||||
|
||||
A status never seen on this (agent, task) is genuine forward progress
|
||||
and fully resets, exactly as before. A REVISITED status — the A<->B
|
||||
oscillation (blocked <-> in_progress) that changes status on every
|
||||
spawn while advancing nothing (2026-07-02: a dev looped 2h/8 spawns
|
||||
without tripping the gate) — gets a bounded reset budget mirroring
|
||||
tracing_resets, after which strikes accrue. seen_statuses is
|
||||
in-memory only (not a tracker column): after a restart it rebuilds
|
||||
from observed statuses, which can only under-gate briefly — never
|
||||
over-gate.
|
||||
"""
|
||||
agent_slug, task_id = key
|
||||
seen = record.get("seen_statuses") or [record.get("last_status")]
|
||||
if current_status not in seen:
|
||||
self._pm_respawn_tracker[key] = {
|
||||
"count": 1,
|
||||
"last_status": current_status,
|
||||
"last_check": now,
|
||||
"seen_statuses": [*seen, current_status],
|
||||
}
|
||||
self._schedule_respawn_persist(
|
||||
agent_slug, str(task_id), self._pm_respawn_tracker[key]
|
||||
)
|
||||
return True
|
||||
record["last_status"] = current_status
|
||||
revisits = record.get("revisit_resets", 0)
|
||||
if revisits < self._PM_RESPAWN_MAX_REVISIT_RESETS:
|
||||
record["revisit_resets"] = revisits + 1
|
||||
record["count"] = 1
|
||||
record["last_check"] = now
|
||||
record["notified"] = False
|
||||
self._schedule_respawn_persist(agent_slug, str(task_id), record)
|
||||
return True
|
||||
logger.warning(
|
||||
"PM respawn status ping-pong budget exhausted — "
|
||||
"revisited statuses no longer reset the strike counter",
|
||||
agent_id=agent_slug,
|
||||
task_id=str(task_id),
|
||||
task_status=current_status,
|
||||
revisit_resets=revisits,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _pm_respawn_should_gate(
|
||||
self, agent_slug: str, task: dict[str, Any]
|
||||
@@ -9827,16 +9906,21 @@ Start now: evidence(task_id="{task_id}")
|
||||
current_status = task.get("status")
|
||||
record = self._pm_respawn_tracker.get(key)
|
||||
now = datetime.now(UTC)
|
||||
if record is None or record.get("last_status") != current_status:
|
||||
if record is None:
|
||||
self._pm_respawn_tracker[key] = {
|
||||
"count": 1,
|
||||
"last_status": current_status,
|
||||
"last_check": now,
|
||||
"seen_statuses": [current_status],
|
||||
}
|
||||
self._schedule_respawn_persist(
|
||||
agent_slug, str(task_id), self._pm_respawn_tracker[key]
|
||||
)
|
||||
return False
|
||||
if record.get("last_status") != current_status and (
|
||||
self._respawn_status_change_resets(key, record, current_status, now)
|
||||
):
|
||||
return False
|
||||
# Same status as last spawn — could be a stuck loop OR a
|
||||
# rule-following retry. A tracing_gap normally means the agent is
|
||||
# advancing through a verb chain, so reset the strike counter — but
|
||||
@@ -10884,20 +10968,16 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
|
||||
# QA already running, they'll pick up on scan
|
||||
continue
|
||||
|
||||
# Respawn circuit breaker — before claiming, so a wedged QA task
|
||||
# doesn't churn claims while the gate is open.
|
||||
# Respawn circuit breaker — same progress-aware gate as every
|
||||
# other task-keyed spawn path.
|
||||
if await self._pm_respawn_should_gate(agent_id, task):
|
||||
continue
|
||||
# Claim the task for QA agent BEFORE spawning
|
||||
if not await self._claim_task_for_agent(client, task["id"], agent_id):
|
||||
logger.warning(
|
||||
"Failed to claim awaiting_qa task for QA",
|
||||
task_id=task["id"],
|
||||
agent_id=agent_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Spawn QA agent with task assignment
|
||||
# NO pre-claim (matches _spawn_assigned_qa and the external-PR
|
||||
# reviewer dispatch): the transitioning claim moved the task to
|
||||
# 'claimed' before the agent existed, stranding the QA whose own
|
||||
# claim_review/pass_review demand awaiting_qa (live 2026-07-02,
|
||||
# ba7b751c). The agent claims itself via claim_review; the
|
||||
# _is_agent_active guard prevents a double-spawn across ticks.
|
||||
await self.spawn_agent(
|
||||
agent_id=agent_id,
|
||||
task_id=task["id"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""RoboCo HTTP security layer — fastapi-guard 7.2.0 / guard-core 3.3.0.
|
||||
"""RoboCo HTTP security layer — fastapi-guard 7.2.2 / guard-core 3.3.0.
|
||||
|
||||
A ``SecurityMiddleware`` + per-route decorator layer, gated by
|
||||
``settings.guard_enabled`` (default off). Importing this module is always safe:
|
||||
|
||||
@@ -4035,6 +4035,24 @@ class GitService(BaseService):
|
||||
unmerged = [line for line in cherry.stdout.splitlines() if line.startswith("+")]
|
||||
if not unmerged:
|
||||
return None
|
||||
# Squash-merge relief: cherry can't patch-match N child commits against
|
||||
# the one squashed commit, but every commit (incl. the squash) carries
|
||||
# the [taskid8] prefix — a marker commit on the parent proves the child
|
||||
# landed (live false positive 2026-07-02: 3 squash-merged children).
|
||||
marker = await self._run_git(
|
||||
workspace,
|
||||
[
|
||||
"log",
|
||||
f"origin/{parent_branch}",
|
||||
"--grep",
|
||||
rf"\[{str(child.id)[:8]}\]",
|
||||
"--oneline",
|
||||
"-1",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if marker.returncode == 0 and marker.stdout.strip():
|
||||
return None
|
||||
return {
|
||||
"task_id": str(child.id)[:8],
|
||||
"title": str(getattr(child, "title", ""))[:80],
|
||||
@@ -4341,10 +4359,26 @@ class GitService(BaseService):
|
||||
await self._run_git(
|
||||
workspace, ["fetch", "origin", branch_name], check=False, token=token
|
||||
)
|
||||
if await self._ref_exists(workspace, branch_name):
|
||||
origin_ref = f"origin/{branch_name}"
|
||||
local_exists = await self._ref_exists(workspace, branch_name)
|
||||
origin_exists = await self._ref_exists(workspace, origin_ref)
|
||||
if local_exists and origin_exists:
|
||||
# An assembled branch advances on ORIGIN when child PRs merge on
|
||||
# GitHub, while the inspecting clone's local ref stays parked — a
|
||||
# diff off the stale local ref re-flags work that already landed
|
||||
# (live 2026-07-02: two false pr_fails on the S6 cell PR). Prefer
|
||||
# origin when the local ref is strictly behind it; a local ref
|
||||
# that is ahead (unpushed) or diverged keeps priority.
|
||||
behind = await self._run_git(
|
||||
workspace,
|
||||
["merge-base", "--is-ancestor", branch_name, origin_ref],
|
||||
check=False,
|
||||
)
|
||||
return origin_ref if behind.returncode == 0 else branch_name
|
||||
if local_exists:
|
||||
return branch_name
|
||||
if await self._ref_exists(workspace, f"origin/{branch_name}"):
|
||||
return f"origin/{branch_name}"
|
||||
if origin_exists:
|
||||
return origin_ref
|
||||
return branch_name
|
||||
|
||||
async def diff(
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Team-match must actually fire: the spec gate rejects cross-team actors.
|
||||
|
||||
Live 2026-07-02: a frontend cell PM was dispatched onto a BACKEND task's
|
||||
review, then blocked it, escalated it, and briefly held it — a 40-minute
|
||||
ownership tug-of-war. Seventeen ActionSpecs carry needs_team_match=True and
|
||||
_check_team_match enforces it — but only when the caller supplies
|
||||
Context.agent_team, which no choreographer site did, so the gate sat in its
|
||||
permissive fallback forever. These tests pin the policy behavior the
|
||||
choreographer sweep wires up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.foundation.policy.lifecycle import (
|
||||
Context,
|
||||
Role,
|
||||
can_invoke_intent,
|
||||
)
|
||||
from roboco.models.base import TaskStatus
|
||||
|
||||
|
||||
def _task(**overrides: Any) -> Any:
|
||||
base: dict[str, Any] = {
|
||||
"id": uuid4(),
|
||||
"status": TaskStatus.IN_PROGRESS,
|
||||
"team": "backend",
|
||||
"assigned_to": None,
|
||||
"task_type": "code",
|
||||
}
|
||||
base.update(overrides)
|
||||
return cast("Any", SimpleNamespace(**base))
|
||||
|
||||
|
||||
def test_cross_team_developer_is_rejected_when_team_supplied() -> None:
|
||||
decision = can_invoke_intent(
|
||||
Role.DEVELOPER,
|
||||
"i_am_blocked",
|
||||
_task(team="backend", status=TaskStatus.IN_PROGRESS, task_type="code"),
|
||||
Context(actor_id=uuid4(), agent_team="frontend"),
|
||||
)
|
||||
assert not decision.allowed
|
||||
assert "team" in (decision.message or "").lower()
|
||||
|
||||
|
||||
def test_cross_team_cell_pm_resume_is_rejected() -> None:
|
||||
decision = can_invoke_intent(
|
||||
Role.CELL_PM,
|
||||
"resume",
|
||||
_task(team="backend", status=TaskStatus.PAUSED),
|
||||
Context(actor_id=uuid4(), agent_team="frontend"),
|
||||
)
|
||||
assert not decision.allowed
|
||||
assert "team" in (decision.message or "").lower()
|
||||
|
||||
|
||||
def test_same_team_developer_is_allowed() -> None:
|
||||
decision = can_invoke_intent(
|
||||
Role.DEVELOPER,
|
||||
"i_am_blocked",
|
||||
_task(team="backend", status=TaskStatus.IN_PROGRESS, task_type="code"),
|
||||
Context(actor_id=uuid4(), agent_team="backend"),
|
||||
)
|
||||
assert decision.allowed
|
||||
|
||||
|
||||
def test_missing_team_keeps_permissive_fallback() -> None:
|
||||
"""Absent agent_team defers to the service layer (backward compatible)."""
|
||||
decision = can_invoke_intent(
|
||||
Role.DEVELOPER,
|
||||
"i_am_blocked",
|
||||
_task(team="backend", status=TaskStatus.IN_PROGRESS, task_type="code"),
|
||||
Context(actor_id=uuid4()),
|
||||
)
|
||||
assert decision.allowed
|
||||
|
||||
|
||||
def test_org_wide_roles_are_exempt_cross_team() -> None:
|
||||
"""Main PM handles every cell's escalations; the exemption keeps that."""
|
||||
for role, verb, status in (
|
||||
(Role.MAIN_PM, "resume", TaskStatus.PAUSED),
|
||||
(Role.MAIN_PM, "unblock", TaskStatus.BLOCKED),
|
||||
):
|
||||
decision = can_invoke_intent(
|
||||
role,
|
||||
verb,
|
||||
_task(team="backend", status=status),
|
||||
Context(actor_id=uuid4(), agent_team="main_pm"),
|
||||
)
|
||||
assert decision.allowed, f"{role} {verb} must stay org-wide"
|
||||
|
||||
|
||||
def test_cross_team_cell_pm_unblock_is_rejected() -> None:
|
||||
decision = can_invoke_intent(
|
||||
Role.CELL_PM,
|
||||
"unblock",
|
||||
_task(team="backend", status=TaskStatus.BLOCKED),
|
||||
Context(actor_id=uuid4(), agent_team="frontend"),
|
||||
)
|
||||
assert not decision.allowed
|
||||
assert "team" in (decision.message or "").lower()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Task list summary mode — trimmed payloads for panel list views.
|
||||
|
||||
The panel fetched /api/tasks unbounded and full-fat (2MB measured live,
|
||||
2026-07-02): every list row shipped description, plan, progress_updates,
|
||||
commits, notes. TaskSummaryResponse existed but was dead code. These tests
|
||||
pin the wired-up summary path: the converter carries exactly the fields
|
||||
list views render (tree, kanban card, git badge), excludes the fat columns,
|
||||
and the /summary route is registered before /{task_id} so it can't be
|
||||
swallowed by the UUID path match.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.api.routes import tasks as routes_mod
|
||||
from roboco.api.routes.tasks import router
|
||||
from roboco.api.schemas.tasks import (
|
||||
_SUMMARY_SNIPPET_LEN,
|
||||
task_list_to_summary_response,
|
||||
task_to_summary_response,
|
||||
)
|
||||
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from roboco.db.tables import TaskTable
|
||||
|
||||
_LIMIT = 2
|
||||
|
||||
|
||||
def _stub_task(**overrides: Any) -> TaskTable:
|
||||
base: dict[str, Any] = {
|
||||
"id": uuid4(),
|
||||
"title": "t",
|
||||
"description": "d" * (_SUMMARY_SNIPPET_LEN * 2 + 100),
|
||||
"status": TaskStatus.PENDING,
|
||||
"priority": 3,
|
||||
"sequence": 1,
|
||||
"nature": TaskNature.TECHNICAL,
|
||||
"task_type": TaskType.CODE,
|
||||
"team": Team.BACKEND,
|
||||
"assigned_to": uuid4(),
|
||||
"parent_task_id": uuid4(),
|
||||
"batch_id": None,
|
||||
"project_id": uuid4(),
|
||||
"product_id": None,
|
||||
"branch_name": "feature/backend/x",
|
||||
"pr_number": 42,
|
||||
"pr_url": "https://github.com/x/y/pull/42",
|
||||
"pr_created": True,
|
||||
"docs_complete": False,
|
||||
"created_at": datetime.now(UTC),
|
||||
"updated_at": datetime.now(UTC),
|
||||
"completed_at": datetime.now(UTC),
|
||||
"board_review_complete": True,
|
||||
"estimated_complexity": Complexity.MEDIUM,
|
||||
}
|
||||
base.update(overrides)
|
||||
return cast("TaskTable", SimpleNamespace(**base))
|
||||
|
||||
|
||||
def test_summary_carries_every_list_view_field() -> None:
|
||||
t = _stub_task()
|
||||
s = task_to_summary_response(t)
|
||||
assert (s.id, s.title, s.status) == (t.id, "t", TaskStatus.PENDING)
|
||||
assert s.parent_task_id == t.parent_task_id # tree build
|
||||
assert s.sequence == 1 and s.task_type is TaskType.CODE # kanban card
|
||||
assert (s.pr_number, s.pr_created, s.docs_complete) == (
|
||||
42,
|
||||
True,
|
||||
False,
|
||||
) # git badge
|
||||
assert s.branch_name == "feature/backend/x"
|
||||
assert s.project_id == t.project_id and s.product_id is None
|
||||
# velocity metrics filter on completion time; the CEO approval queue
|
||||
# gates on board_review_complete — both burned as gaps on 2026-07-02
|
||||
assert s.completed_at == t.completed_at
|
||||
assert s.board_review_complete is True
|
||||
|
||||
|
||||
def test_summary_excludes_fat_fields_and_truncates_snippet() -> None:
|
||||
s = task_to_summary_response(_stub_task())
|
||||
dump = s.model_dump()
|
||||
for fat in (
|
||||
"description",
|
||||
"plan",
|
||||
"progress_updates",
|
||||
"commits",
|
||||
"quick_context",
|
||||
"checkpoints",
|
||||
"notes_structured",
|
||||
"dev_notes",
|
||||
"acceptance_criteria",
|
||||
):
|
||||
assert fat not in dump, f"summary must not carry {fat}"
|
||||
assert len(s.description_snippet or "") == _SUMMARY_SNIPPET_LEN
|
||||
|
||||
|
||||
def test_summary_snippet_none_safe() -> None:
|
||||
assert (
|
||||
task_to_summary_response(_stub_task(description=None)).description_snippet
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
task_to_summary_response(_stub_task(description="")).description_snippet is None
|
||||
)
|
||||
|
||||
|
||||
def test_summary_list_converter() -> None:
|
||||
stubs = [_stub_task() for _ in range(_LIMIT)]
|
||||
assert len(task_list_to_summary_response(stubs)) == len(stubs)
|
||||
|
||||
|
||||
def test_summary_route_registered_before_task_id_route() -> None:
|
||||
"""/tasks/summary must not be swallowed by /tasks/{task_id} UUID parsing."""
|
||||
paths = [getattr(r, "path", "") for r in router.routes]
|
||||
assert "/summary" in paths
|
||||
assert paths.index("/summary") < paths.index("/{task_id}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_route_status_branch_respects_limit() -> None:
|
||||
service = AsyncMock()
|
||||
service.list_by_status.return_value = [_stub_task() for _ in range(_LIMIT * 3)]
|
||||
permissions = MagicMock()
|
||||
permissions.can_perform_task_action.return_value = True
|
||||
agent = MagicMock(team=Team.BACKEND)
|
||||
with (
|
||||
patch.object(routes_mod, "get_task_service", return_value=service),
|
||||
patch.object(routes_mod, "get_permission_service", return_value=permissions),
|
||||
):
|
||||
out = await routes_mod.list_tasks_summary(
|
||||
db=MagicMock(),
|
||||
agent=agent,
|
||||
team=None,
|
||||
status=TaskStatus.PENDING,
|
||||
limit=_LIMIT,
|
||||
)
|
||||
assert len(out) == _LIMIT
|
||||
@@ -0,0 +1,147 @@
|
||||
"""The delegate MCP tool must be able to send every field the gate demands.
|
||||
|
||||
Original bug (2026-07-02 live): TASK_AT_DELEGATE required ``intends_to_touch``
|
||||
on code delegations, but the MCP ``delegate`` tool had no such parameter —
|
||||
PMs were rejected 4x with ``incomplete_input``, could never comply, and
|
||||
blocked/escalated. Fleet-wide code-delegation wall.
|
||||
|
||||
Invariant: every FieldRequirement in TASK_AT_DELEGATE (and TASK_AT_CREATE,
|
||||
which it extends) is either a parameter of the MCP delegate tool or
|
||||
server-resolved (never demanded from the caller).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from roboco.foundation.policy.task_completeness import TASK_AT_DELEGATE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
# Fields the choreographer resolves server-side; the tool never sends them.
|
||||
_SERVER_RESOLVED = {"project_id"}
|
||||
|
||||
|
||||
def _pm_manifest() -> dict[str, object]:
|
||||
return {
|
||||
"agent_id": "00000000-0000-0000-0000-000000000098",
|
||||
"role": "cell_pm",
|
||||
"team": "frontend",
|
||||
"workspace_path": "/tmp/test",
|
||||
"flow_tools": ["delegate", "i_am_idle"],
|
||||
"do_tools": [],
|
||||
"read_tools": [],
|
||||
"write_tools": [],
|
||||
"bash_allowed": True,
|
||||
"subagent_allowed": False,
|
||||
"subagent_model": None,
|
||||
"env": {},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def flow_module_pm(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> types.ModuleType:
|
||||
manifest_path = tmp_path / "tool-manifest.json"
|
||||
manifest_path.write_text(json.dumps(_pm_manifest()))
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000098")
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "cell_pm")
|
||||
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
|
||||
monkeypatch.setenv("ROBOCO_SDK_URL", "http://test-sdk:9000")
|
||||
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path))
|
||||
|
||||
import roboco.mcp.flow_server as srv
|
||||
|
||||
importlib.reload(srv)
|
||||
return srv
|
||||
|
||||
|
||||
def test_delegate_tool_covers_every_gate_required_field(
|
||||
flow_module_pm: types.ModuleType,
|
||||
) -> None:
|
||||
"""Every TASK_AT_DELEGATE FieldRequirement is a delegate() parameter."""
|
||||
params = set(inspect.signature(flow_module_pm.delegate).parameters)
|
||||
required = {req.field for req in TASK_AT_DELEGATE.requires}
|
||||
missing = required - params - _SERVER_RESOLVED
|
||||
assert not missing, (
|
||||
f"TASK_AT_DELEGATE demands fields the MCP delegate tool cannot send: "
|
||||
f"{sorted(missing)}. A PM rejected with incomplete_input for these "
|
||||
f"can NEVER comply — add them to flow_server.delegate and forward "
|
||||
f"them in the payload."
|
||||
)
|
||||
|
||||
|
||||
# Choreographer plan-depth gates hard-reject with `missing=[...]` naming these
|
||||
# fields (_pm_sub_tasks_gate for i_will_plan; the dev rich-plan gate for
|
||||
# i_will_work_on). The named tool must be able to send every one of them, or
|
||||
# the rejected agent can never comply — the delegate/intends_to_touch wall.
|
||||
_PLAN_GATE_FIELDS: dict[str, set[str]] = {
|
||||
"i_will_plan": {"plan", "approach", "sub_tasks"},
|
||||
"i_will_work_on": {"plan", "steps", "technical_considerations", "risks"},
|
||||
}
|
||||
|
||||
|
||||
def test_plan_gate_fields_are_tool_parameters(
|
||||
flow_module_pm: types.ModuleType,
|
||||
) -> None:
|
||||
"""Every field a plan gate can demand exists on the corresponding tool."""
|
||||
for verb, required in _PLAN_GATE_FIELDS.items():
|
||||
params = set(inspect.signature(getattr(flow_module_pm, verb)).parameters)
|
||||
missing = required - params
|
||||
assert not missing, (
|
||||
f"{verb} gate demands fields the MCP tool cannot send: "
|
||||
f"{sorted(missing)} — same class as the delegate wall."
|
||||
)
|
||||
|
||||
|
||||
def test_delegate_forwards_collision_surface_in_payload(
|
||||
flow_module_pm: types.ModuleType,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The surface fields actually reach the POST body (not just the signature)."""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _client_factory(*_a: object, **_kw: object) -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.__enter__ = MagicMock(return_value=client)
|
||||
client.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
def _post(url: str, **kwargs: object) -> MagicMock:
|
||||
captured["url"] = url
|
||||
captured["json"] = kwargs.get("json")
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = {"status": "ok"}
|
||||
return resp
|
||||
|
||||
client.post = _post
|
||||
return client
|
||||
|
||||
monkeypatch.setattr(flow_module_pm.httpx, "Client", _client_factory)
|
||||
flow_module_pm.delegate(
|
||||
parent_task_id="00000000-0000-0000-0000-000000000001",
|
||||
title="t",
|
||||
description="a description well over twenty chars",
|
||||
assigned_to="fe-dev-1",
|
||||
team="frontend",
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["done"],
|
||||
intends_to_touch=["frontend/src/components/behavioral-content.tsx"],
|
||||
adds_migration=False,
|
||||
touches_shared=True,
|
||||
depends_on=["00000000-0000-0000-0000-000000000002"],
|
||||
)
|
||||
body = captured["json"]
|
||||
assert body["intends_to_touch"] == [
|
||||
"frontend/src/components/behavioral-content.tsx"
|
||||
]
|
||||
assert body["adds_migration"] is False
|
||||
assert body["touches_shared"] is True
|
||||
assert body["depends_on"] == ["00000000-0000-0000-0000-000000000002"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""QA dispatch must not pre-claim the review task.
|
||||
|
||||
Live 2026-07-02 (ba7b751c): the unassigned-QA branch claimed the task
|
||||
BEFORE spawning (awaiting_qa -> claimed via the transitioning claim), then
|
||||
spawned a QA agent whose own verbs demand awaiting_qa — claim_review bounced
|
||||
("cannot claim from 'claimed'"), pass_review bounced, and the agent gave up
|
||||
and unclaimed. The assigned-QA branch and the external-PR reviewer dispatch
|
||||
both already spawn WITHOUT pre-claiming (the agent claims itself via
|
||||
claim_review); the unassigned branch must match.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _orch() -> tuple[AgentOrchestrator, AsyncMock, AsyncMock]:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._pm_respawn_tracker = {}
|
||||
orch._bg_tasks = set()
|
||||
any_orch = cast("Any", orch)
|
||||
any_orch._is_task_handled_this_tick = lambda _tid: False
|
||||
any_orch._select_agent_for_cell = lambda _team, _role: "be-qa"
|
||||
any_orch._is_agent_active = lambda _slug: False
|
||||
any_orch._pm_respawn_should_gate = AsyncMock(return_value=False)
|
||||
any_orch._build_qa_prompt = lambda _t: "review it"
|
||||
any_orch._task_git_context = lambda _t: None
|
||||
claim = AsyncMock(return_value=True)
|
||||
spawn = AsyncMock()
|
||||
any_orch._claim_task_for_agent = claim
|
||||
any_orch.spawn_agent = spawn
|
||||
return orch, claim, spawn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unassigned_qa_dispatch_spawns_without_preclaim() -> None:
|
||||
orch, claim, spawn = _orch()
|
||||
task = {"id": str(uuid4()), "team": "backend", "assigned_to": None}
|
||||
cast("Any", orch)._fetch_tasks = AsyncMock(return_value=[task])
|
||||
|
||||
await orch._dispatch_qa_work(MagicMock())
|
||||
|
||||
claim.assert_not_awaited()
|
||||
spawn.assert_awaited_once()
|
||||
spawn_call = spawn.await_args
|
||||
assert spawn_call is not None
|
||||
assert spawn_call.kwargs["task_id"] == task["id"]
|
||||
assert spawn_call.kwargs["agent_id"] == "be-qa"
|
||||
@@ -221,3 +221,40 @@ class TestBuildManifestForAgent:
|
||||
assert result is not None
|
||||
assert nested.exists()
|
||||
assert result.exists()
|
||||
|
||||
|
||||
class TestManifestWorkspacePath:
|
||||
"""workspace_path must be the TASK-resolved workspace, not the roboco default.
|
||||
|
||||
Live 2026-07-02: be-dev-2's manifest said /data/workspaces/roboco/... while
|
||||
its task lived in guard-core-saas-backend — an agent trusting the manifest
|
||||
hunts for its files in the wrong repository.
|
||||
"""
|
||||
|
||||
def test_workspace_override_reaches_manifest(self, tmp_path: Path) -> None:
|
||||
worktree = (
|
||||
"/data/workspaces/guard-core-saas-backend/backend/be-dev-1"
|
||||
"/.worktrees/abc12345"
|
||||
)
|
||||
with patch("roboco.runtime.orchestrator.settings") as mock_settings:
|
||||
mock_settings.manifest_host_dir = str(tmp_path)
|
||||
mock_settings.workspaces_root = str(tmp_path / "workspaces")
|
||||
|
||||
result = _build_manifest_for_agent(
|
||||
"be-dev-1", "claude-sonnet-5", workspace_path=worktree
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
data = json.loads(result.read_text())
|
||||
assert data["workspace_path"] == worktree
|
||||
|
||||
def test_no_override_keeps_roboco_default(self, tmp_path: Path) -> None:
|
||||
with patch("roboco.runtime.orchestrator.settings") as mock_settings:
|
||||
mock_settings.manifest_host_dir = str(tmp_path)
|
||||
mock_settings.workspaces_root = str(tmp_path / "workspaces")
|
||||
|
||||
result = _build_manifest_for_agent("be-dev-1", "claude-sonnet-5")
|
||||
|
||||
assert result is not None
|
||||
data = json.loads(result.read_text())
|
||||
assert data["workspace_path"].endswith("workspaces/roboco/backend/be-dev-1")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""The respawn breaker must not be fooled by status ping-pong.
|
||||
|
||||
Live 2026-07-02: a dev looped blocked -> in_progress -> blocked for two hours
|
||||
(8 spawns, 30 gateway rejections) and the breaker never tripped — every
|
||||
status CHANGE fully reset the strike counter, and an A<->B oscillation
|
||||
changes status on every spawn. A revisited status now gets a bounded reset
|
||||
budget (mirroring tracing_resets); genuinely new statuses keep the full
|
||||
reset so forward progress is never punished.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _new_orchestrator() -> AgentOrchestrator:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._pm_respawn_tracker = {}
|
||||
orch._bg_tasks = set()
|
||||
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
|
||||
return orch
|
||||
|
||||
|
||||
def _quiet_audit() -> AsyncMock:
|
||||
audit = AsyncMock()
|
||||
audit.has_recent_tracing_gap = AsyncMock(return_value=False)
|
||||
return audit
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_ping_pong_eventually_trips_the_gate() -> None:
|
||||
"""blocked <-> in_progress oscillation accrues strikes past the budget."""
|
||||
orch = _new_orchestrator()
|
||||
task_id = str(uuid4())
|
||||
statuses = ["blocked", "in_progress"] * 6
|
||||
results = []
|
||||
with (
|
||||
patch("roboco.services.audit.get_audit_service", return_value=_quiet_audit()),
|
||||
patch(
|
||||
"roboco.services.notification.NotificationService",
|
||||
return_value=AsyncMock(),
|
||||
),
|
||||
):
|
||||
for status in statuses:
|
||||
results.append(
|
||||
await orch._pm_respawn_should_gate(
|
||||
"be-dev-1", {"id": task_id, "status": status}
|
||||
)
|
||||
)
|
||||
assert any(results), (
|
||||
"an A<->B status oscillation never accumulated strikes — the exact "
|
||||
"2026-07-02 two-hour loop the breaker exists to stop"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_progress_through_new_statuses_never_gates() -> None:
|
||||
orch = _new_orchestrator()
|
||||
task_id = str(uuid4())
|
||||
lifecycle = ["pending", "claimed", "in_progress", "verifying", "awaiting_qa"]
|
||||
with (
|
||||
patch("roboco.services.audit.get_audit_service", return_value=_quiet_audit()),
|
||||
patch(
|
||||
"roboco.services.notification.NotificationService",
|
||||
return_value=AsyncMock(),
|
||||
),
|
||||
):
|
||||
for status in lifecycle:
|
||||
assert not await orch._pm_respawn_should_gate(
|
||||
"be-dev-1", {"id": task_id, "status": status}
|
||||
), f"forward progress into {status} must not gate"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_revisit_within_budget_does_not_gate() -> None:
|
||||
"""A legitimate revision cycle (one revisit) stays under the budget."""
|
||||
orch = _new_orchestrator()
|
||||
task_id = str(uuid4())
|
||||
with (
|
||||
patch("roboco.services.audit.get_audit_service", return_value=_quiet_audit()),
|
||||
patch(
|
||||
"roboco.services.notification.NotificationService",
|
||||
return_value=AsyncMock(),
|
||||
),
|
||||
):
|
||||
for status in ["in_progress", "awaiting_qa", "in_progress", "awaiting_qa"]:
|
||||
assert not await orch._pm_respawn_should_gate(
|
||||
"be-dev-1", {"id": task_id, "status": status}
|
||||
), "one revision round-trip must not trip the breaker"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""_cherry_unmerged_entry must not flag squash-merged children as missing.
|
||||
|
||||
Live false positive (2026-07-02): three children of the S6 cell task were
|
||||
squash-merged (PRs #176/#185/#190) — their commits sat at the assembled
|
||||
branch tip, yet ``git cherry`` reported every individual child commit as
|
||||
unmerged (a squash rewrites N patches into one patch-id) and the assembly
|
||||
integrity guard refused every legitimate submit_up.
|
||||
|
||||
Relief: every commit — including the squash commit — carries the
|
||||
``[taskid8]`` prefix, so a marker-bearing commit on the parent proves the
|
||||
child landed. A child with no marker on the parent stays flagged (the
|
||||
original incident #11 the guard exists for).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.git import GitService
|
||||
|
||||
|
||||
def _svc_with_git_responses(
|
||||
responses: dict[str, SimpleNamespace],
|
||||
) -> tuple[GitService, list[list[str]]]:
|
||||
"""GitService with _run_git stubbed by subcommand name; records calls."""
|
||||
svc = GitService.__new__(GitService)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
async def _run_git(
|
||||
_workspace: Path, args: list[str], **_kw: Any
|
||||
) -> SimpleNamespace:
|
||||
calls.append(args)
|
||||
return responses[args[0]]
|
||||
|
||||
svc_any: Any = svc
|
||||
svc_any._run_git = _run_git
|
||||
return svc, calls
|
||||
|
||||
|
||||
def _child() -> MagicMock:
|
||||
return MagicMock(
|
||||
id=uuid4(), branch_name="feature/frontend/root--cell--child", title="t"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_squash_merged_child_with_task_marker_is_not_flagged() -> None:
|
||||
"""cherry says unmerged, but the [taskid8] squash commit is on the parent."""
|
||||
svc, calls = _svc_with_git_responses(
|
||||
{
|
||||
"rev-parse": SimpleNamespace(returncode=0, stdout="abc\n"),
|
||||
"cherry": SimpleNamespace(returncode=0, stdout="+ aaa\n+ bbb\n"),
|
||||
"log": SimpleNamespace(
|
||||
returncode=0, stdout="4771bd71 [deadbeef] title (#190)\n"
|
||||
),
|
||||
}
|
||||
)
|
||||
entry = await svc._cherry_unmerged_entry(Path("/tmp"), "parent", _child())
|
||||
assert entry is None
|
||||
log_call = next(c for c in calls if c[0] == "log")
|
||||
assert any("\\[" in arg for arg in log_call) # grep pattern escapes the bracket
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_genuinely_missing_child_stays_flagged() -> None:
|
||||
"""No marker commit on the parent → the original #11 catch still fires."""
|
||||
child = _child()
|
||||
svc, _calls = _svc_with_git_responses(
|
||||
{
|
||||
"rev-parse": SimpleNamespace(returncode=0, stdout="abc\n"),
|
||||
"cherry": SimpleNamespace(returncode=0, stdout="+ aaa\n"),
|
||||
"log": SimpleNamespace(returncode=0, stdout=""),
|
||||
}
|
||||
)
|
||||
entry = await svc._cherry_unmerged_entry(Path("/tmp"), "parent", child)
|
||||
assert entry == {"task_id": str(child.id)[:8], "title": "t", "unmerged": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cherry_clean_short_circuits_without_marker_probe() -> None:
|
||||
"""No + lines from cherry → merged; the log probe is never run."""
|
||||
svc, calls = _svc_with_git_responses(
|
||||
{
|
||||
"rev-parse": SimpleNamespace(returncode=0, stdout="abc\n"),
|
||||
"cherry": SimpleNamespace(returncode=0, stdout="- aaa\n"),
|
||||
}
|
||||
)
|
||||
entry = await svc._cherry_unmerged_entry(Path("/tmp"), "parent", _child())
|
||||
assert entry is None
|
||||
assert not any(c[0] == "log" for c in calls)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""_resolve_head_ref must not diff off a stale local ref.
|
||||
|
||||
Live incident (2026-07-02): the S6 cell branch advanced on ORIGIN as child
|
||||
PRs squash-merged on GitHub, but the assignee clone's local ref stayed
|
||||
parked pre-merge. ``diff()`` preferred the local ref, so the PR-gate
|
||||
reviewer's evidence diff re-flagged work that had already landed — two
|
||||
false ``pr_fail`` verdicts on a clean PR.
|
||||
|
||||
Rule: when both refs exist and the local ref is STRICTLY BEHIND origin,
|
||||
use ``origin/<branch>``; a local ref that is ahead (unpushed commits) or
|
||||
diverged keeps priority, and single-ref cases are unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from roboco.services.git import GitService
|
||||
|
||||
_BRANCH = "feature/frontend/root--cell"
|
||||
_ORIGIN = f"origin/{_BRANCH}"
|
||||
|
||||
|
||||
def _svc(*, refs: set[str], ancestor_rc: int) -> tuple[GitService, list[list[str]]]:
|
||||
svc = GitService.__new__(GitService)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
async def _run_git(
|
||||
_workspace: Path, args: list[str], **_kw: Any
|
||||
) -> SimpleNamespace:
|
||||
calls.append(args)
|
||||
if args[0] == "merge-base":
|
||||
return SimpleNamespace(returncode=ancestor_rc, stdout="")
|
||||
return SimpleNamespace(returncode=0, stdout="")
|
||||
|
||||
async def _ref_exists(_workspace: Path, ref: str) -> bool:
|
||||
return ref in refs
|
||||
|
||||
svc_any: Any = svc
|
||||
svc_any._run_git = _run_git
|
||||
svc_any._ref_exists = _ref_exists
|
||||
return svc, calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_behind_origin_resolves_to_origin() -> None:
|
||||
svc, calls = _svc(refs={_BRANCH, _ORIGIN}, ancestor_rc=0)
|
||||
ref = await svc._resolve_head_ref(Path("/tmp"), _BRANCH)
|
||||
assert ref == _ORIGIN
|
||||
ancestor = next(c for c in calls if c[0] == "merge-base")
|
||||
assert ancestor == ["merge-base", "--is-ancestor", _BRANCH, _ORIGIN]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_ahead_or_diverged_keeps_local() -> None:
|
||||
svc, _calls = _svc(refs={_BRANCH, _ORIGIN}, ancestor_rc=1)
|
||||
assert await svc._resolve_head_ref(Path("/tmp"), _BRANCH) == _BRANCH
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_local_ref_unchanged() -> None:
|
||||
svc, calls = _svc(refs={_BRANCH}, ancestor_rc=1)
|
||||
assert await svc._resolve_head_ref(Path("/tmp"), _BRANCH) == _BRANCH
|
||||
assert not any(c[0] == "merge-base" for c in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_origin_ref_unchanged() -> None:
|
||||
svc, _calls = _svc(refs={_ORIGIN}, ancestor_rc=1)
|
||||
assert await svc._resolve_head_ref(Path("/tmp"), _BRANCH) == _ORIGIN
|
||||
@@ -3158,11 +3158,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "stevedore"
|
||||
version = "5.8.0"
|
||||
version = "5.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/88/35e4d27d9177d7df76d060e0a18f69c6c5794c96960c94042e20a12c8ba2/stevedore-5.8.0.tar.gz", hash = "sha256:b49867b32ca3016e94100e68dbf26e72aa7b8708d0a3f73c08aeb220370ac715", size = 514710, upload-time = "2026-05-18T09:15:27.731Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/dd/04d56c2a5232358df41f3d0f0e31833d378b6c8ed7803a6b1b7867b0eba6/stevedore-5.9.0.tar.gz", hash = "sha256:abbd0af7a38a8bbb1d6adea2e35b17609cf004eaac323e88a8d8963640dd2b3c", size = 514850, upload-time = "2026-07-02T11:38:08.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/ac/19f9941c74add59d17694930ec8105d5eddeee4ce56dd8632b765ca16d6c/stevedore-5.8.0-py3-none-any.whl", hash = "sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b", size = 54553, upload-time = "2026-05-18T09:15:25.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/8d/008761f6e1000600e5303db30d05724bdcf3d2d186cbb59fac79b52e39ed/stevedore-5.9.0-py3-none-any.whl", hash = "sha256:e520945d4c257700eddc1eb1d79df04b2ea578eef185e0e3fa5b442fc848d3f7", size = 54463, upload-time = "2026-07-02T11:38:07.43Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3394,11 +3394,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||