mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
MegaTask umbrella e2e scenario + batch root-subtask completion fix; comms dead-code deletion (#296)
* chore(panel): delete the five dead comms components The comms audit found communications-view, channel-sidebar, channel-item, message-list, and message-item exported but rendered by no page — the live /communications page and the session detail render their own inline content and import only MessageComposer and MessageTypeBadge, which stay. Verified zero consumers outside the dead cluster before deletion; panel gates green (tsc, lint, 187 tests). * feat(tests): e2e scenario 4 — MegaTask umbrella; fix batch root-subtask completion wall Scenario 4 seeds an umbrella + two dependency-linked root-subtasks: sequencing hold proven (unmet_dependency on RS2's i_will_plan while RS1 lives), RS1 completed through the entire real chain to a master merge, hold lifts, RS2 completes, umbrella closes branchless via ceo-approve and never carries a PR. Product fix it surfaced on first run: _main_pm_complete_guard and escalate_to_ceo refused ANY parented task as 'not a root', but a batch root-subtask is parented (the umbrella) BY DESIGN — both sites now consult is_batch_root_subtask, plain subtasks stay refused. Live root-subtasks previously needed CEO god-mode to close. Regression tests added; built subagent-driven (Sonnet 5) and reviewed. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
### Added
|
||||
|
||||
- **e2e scenario 4 — the MegaTask umbrella.** Seeds an umbrella + two dependency-linked root-subtasks; proves the sequencing hold (RS2's `i_will_plan` rejected `unmet_dependency` while RS1 is live), completes RS1 through the entire real chain (dev→QA→doc→PM→gate→CEO `approve-and-merge` to master), verifies the hold lifts, completes RS2, and closes the umbrella through its branchless path — Main-PM `complete` escalates, `POST /tasks/{id}/ceo-approve` (notes ≥ 20) finishes it, and the umbrella never carries a PR. Six scenarios now cover the full company loop in ~50s.
|
||||
|
||||
- **The PR-gate turn cut — assembled parents auto-submit to the reviewer.** When every child of an assembled parent is terminal, the orchestrator used to spawn the PM just to call `submit_up`/`submit_root` — a whole agent turn whose substance (freshness rebase, integrity check, PR open) is deterministic gate code. The closure dispatcher now runs the REAL submit verb through the internal API as the owning PM (`_try_auto_submit`); the task lands in `awaiting_pr_review` and the reviewer dispatch takes it with no PM turn spent. Every gate is intact: a submit rejection (freshness/integrity — the case that genuinely needs judgment) falls back to the classic PM closure spawn, `pr_fail` still routes `needs_revision` to the PM, and the PM keeps the final merge turn. Branchless coordination parents (MegaTask umbrellas) never auto-submit. Gated by `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (default **on**); each auto-submit leaves a `task.auto_submitted` audit row.
|
||||
- **Task search that actually searches.** The task list's search box only matched titles client-side — and the trimmed summary payload deliberately carries no description, so keyword/details/id search was impossible in the browser by design. `GET /tasks/summary` gains `q` (ILIKE over title + description, id-prefix match, composed with team/status filters and view-permission scoping); the panel debounces the box into the fetch and drops the title-only client filter that would have hidden description matches.
|
||||
- **Trace timestamps on structured notes.** Note sections (dev/qa/doc/reviewer/handoff) are overwrite-in-place with no stamp, so there was no way to reconstruct WHEN a note landed. `apply_structured_note` now stamps ISO `written_at` beside the model fields and the panel notes tab renders it next to each card title. Progress updates, commits, and journal entries already carried timestamps — this closed the one gap.
|
||||
@@ -42,6 +44,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MegaTask root-subtasks can complete through the Main-PM path.** `_main_pm_complete_guard` and `TaskService.escalate_to_ceo` refused ANY parented task as "not a root" — but a batch root-subtask is parented (the umbrella) BY DESIGN while carrying its own project/branch/PR. Both sites now consult `is_batch_root_subtask` (the single-source identity predicate the other exemption sites already use), so `complete` → CEO escalation works for batch roots while plain subtasks stay refused. Found by e2e scenario 4 on its first run; live root-subtasks previously needed CEO god-mode to close.
|
||||
|
||||
- **Every `agent.spawned` audit row names its dispatcher.** A rogue spawner could not be identified live — the audit row carried container/model but not which of the ~27 dispatch loops launched it. `spawn_agent` now takes `spawned_by`, stamps it into the `agent.spawned` / `agent.spawn_failed` details (`"unspecified"` when absent so audit queries never miss the field), every call site passes its loop name, and a whole-package AST sweep test fails any future caller that omits it.
|
||||
- **Admin-complete refuses while the task's PR is still open.** `PATCH status=completed` on a task whose work session records an OPEN PR stranded its commits unmerged (bit the CEO twice live). The override now refuses with the PR number/URL and the concrete consequence — merge first, or approve via `POST /tasks/{id}/ceo-approve` — checked before the generic hatch text; `force: true` stays the deliberate, audited escape, and a merged/closed PR changes nothing.
|
||||
- **The panel's CEO-approve button works on every gated task.** On `awaiting_ceo_approval` the task header offered only "Approve & Merge" (`POST /approve-and-merge`, no notes), which 400s `NO_PR` on a branchless MegaTask umbrella — the CEO's approve just failed. The primary action is now "Approve & Complete" through the `CeoApproveDialog` (`POST /ceo-approve`, notes ≥ 20 chars); "Approve & Merge" remains, but only when the task actually carries a PR.
|
||||
|
||||
@@ -595,3 +595,10 @@ Slices touched: orchestrator (1), tests (1, 2), taskservice + api-routes-schemas
|
||||
## Delta 2026-07-02 (late night) — e2e scenario 3 (branch `feat/wave-1`)
|
||||
|
||||
`tests/e2e_smoke/test_root_ceo_chain.py`: 3a pr_fail→needs_revision→`i_will_plan` re-entry (route demands approach≥150 + sub_tasks even on re-claim — pydantic fires before the gateway short-circuit)→real fix commit (`origin_commit` helper)→resubmit→pass→merge; 3b submit_root→gate→complete-escalates→REAL `approve-and-merge` (tasks router now mounted in the harness app; CEO row seeded)→hello.txt on origin master. Seed corrections that ARE the documentation: delivery roots are team=main_pm + planning-typed (backend-team roots get closure-routed to the cell PM; code-typed roots hit the main_pm+code impossibility guard). Fake GitHub `get_pr` now recomputes head.sha live (the unchanged-PR gate reads it via the REST API, not local refs). Latent fix en route: dep-update probe env scrub (VIRTUAL_ENV). uv-rot root cause: shared ~/.cache/uv with long-lived uvx MCP servers — per-repo UV_CACHE_DIR pinned.
|
||||
|
||||
---
|
||||
## Delta 2026-07-03 — wave 2 begins (branch `feat/wave-2`)
|
||||
|
||||
1. **Five dead comms panel components deleted** (−422 lines; audit-verified zero consumers; MessageComposer/MessageTypeBadge stay).
|
||||
2. **e2e scenario 4 (MegaTask umbrella)** — `tests/e2e_smoke/test_megatask_umbrella.py` + arcs helpers (`wire_dependency` via the real sequencing edge, `seed_cell_and_dev`, `set_branch_name`). Proves: sequencing hold (`unmet_dependency` on RS2's i_will_plan), serial root merges to master, umbrella branchless close via ceo-approve (never approve-and-merge).
|
||||
3. **PRODUCT FIX: batch root-subtask completion wall** — `_main_pm_complete_guard` (_impl.py ~6708) + `escalate_to_ceo` (task.py ~5321) refused ALL parented tasks; both now consult `is_batch_root_subtask`. Live root-subtasks previously closed only via CEO god-mode. Regression tests in test_choreographer_pm.py + test_task_service_transitions.py. First product bug found BY the harness (subagent-built, Sonnet 5, reviewed).
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Channel } from "@/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Hash, Lock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChannelItemProps {
|
||||
channel: Channel;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
export function ChannelItem({
|
||||
channel,
|
||||
isSelected,
|
||||
onClick,
|
||||
unreadCount = 0,
|
||||
}: ChannelItemProps) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full h-auto justify-start gap-2 px-2 py-1.5 font-normal whitespace-normal",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{channel.is_private ? (
|
||||
<Lock className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<Hash className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-sm">{channel.name}</span>
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant="destructive" className="h-5 px-1.5 text-xs">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Channel } from "@/types";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ChannelItem } from "./channel-item";
|
||||
|
||||
interface ChannelSidebarProps {
|
||||
channels: Channel[] | undefined;
|
||||
isLoading: boolean;
|
||||
selectedChannelId: string | null;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
}
|
||||
|
||||
// Group channels by type
|
||||
function groupChannels(channels: Channel[]): Record<string, Channel[]> {
|
||||
const groups: Record<string, Channel[]> = {
|
||||
"Cell Channels": [],
|
||||
"Cross-Cell": [],
|
||||
Management: [],
|
||||
Special: [],
|
||||
};
|
||||
|
||||
channels.forEach((channel) => {
|
||||
if (channel.name.includes("-cell")) {
|
||||
groups["Cell Channels"].push(channel);
|
||||
} else if (channel.name.includes("-all")) {
|
||||
groups["Cross-Cell"].push(channel);
|
||||
} else if (channel.name.includes("pm") || channel.name.includes("board")) {
|
||||
groups["Management"].push(channel);
|
||||
} else {
|
||||
groups["Special"].push(channel);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function ChannelSidebar({
|
||||
channels,
|
||||
isLoading,
|
||||
selectedChannelId,
|
||||
onSelectChannel,
|
||||
}: ChannelSidebarProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 p-2">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-8" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!channels || channels.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
No channels available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const grouped = groupChannels(channels);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-200px)]">
|
||||
<div className="p-2 space-y-4">
|
||||
{Object.entries(grouped).map(([group, groupChannels]) => {
|
||||
if (groupChannels.length === 0) return null;
|
||||
return (
|
||||
<div key={group}>
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-2 mb-2">
|
||||
{group}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{groupChannels.map((channel) => (
|
||||
<ChannelItem
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
isSelected={selectedChannelId === channel.id}
|
||||
onClick={() => onSelectChannel(channel.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useChannels } from "@/hooks/use-channels";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ChannelSidebar } from "./channel-sidebar";
|
||||
import { RefreshCw, Hash, Users, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export function CommunicationsView() {
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const { data: channels, isLoading: loadingChannels, refetch } = useChannels();
|
||||
|
||||
// Get selected channel
|
||||
const selectedChannel = channels?.find((c) => c.id === selectedChannelId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Communications</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Browse channels and view messages
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/communications" prefetch={false}>
|
||||
<Button variant="outline">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Full View
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-12 gap-6 h-[calc(100vh-220px)]">
|
||||
{/* Channel Sidebar */}
|
||||
<div className="col-span-12 lg:col-span-3">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm font-medium">Channels</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ChannelSidebar
|
||||
channels={channels}
|
||||
isLoading={loadingChannels}
|
||||
selectedChannelId={selectedChannelId}
|
||||
onSelectChannel={setSelectedChannelId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Channel Info Area */}
|
||||
<div className="col-span-12 lg:col-span-9">
|
||||
<Card className="h-full flex flex-col">
|
||||
{selectedChannel ? (
|
||||
<>
|
||||
{/* Channel Header */}
|
||||
<CardHeader className="py-3 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">
|
||||
{selectedChannel.name}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
{selectedChannel.member_count}
|
||||
</Badge>
|
||||
</div>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/communications?channel=${selectedChannel.id}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Open Channel
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{selectedChannel.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedChannel.description}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{/* Channel Stats */}
|
||||
<CardContent className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div>
|
||||
<p className="text-3xl font-bold">
|
||||
{selectedChannel.message_count}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Messages
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-bold">
|
||||
{selectedChannel.group_count}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Groups</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open the channel to view sessions and send messages
|
||||
</p>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/communications?channel=${selectedChannel.id}`}
|
||||
>
|
||||
<Button>View Sessions</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Hash className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">Select a Channel</p>
|
||||
<p className="text-sm">
|
||||
Choose a channel from the sidebar to view details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,2 @@
|
||||
export { CommunicationsView } from "./communications-view";
|
||||
export { ChannelSidebar } from "./channel-sidebar";
|
||||
export { ChannelItem } from "./channel-item";
|
||||
export { MessageList } from "./message-list";
|
||||
export { MessageItem } from "./message-item";
|
||||
export { MessageComposer } from "./message-composer";
|
||||
export { MessageTypeBadge } from "./message-type-badge";
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Message } from "@/types";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { CopyButton } from "@/components/ui/copy-button";
|
||||
import { MessageTypeBadge } from "./message-type-badge";
|
||||
import { Clock, Link2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function MessageItem({ message }: MessageItemProps) {
|
||||
return (
|
||||
<div className="group relative flex gap-3 py-3 hover:bg-muted/30 px-2 rounded-lg">
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-xs">
|
||||
{getAgentInitials(message.agent_id)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">
|
||||
{getAgentDisplayName(message.agent_id)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatTime(message.timestamp)}
|
||||
</span>
|
||||
<MessageTypeBadge type={message.type} />
|
||||
</div>
|
||||
<div className="text-sm mt-1">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
{/* Mentions */}
|
||||
{message.mentions.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
{message.mentions.map((mention) => (
|
||||
<Badge key={mention} variant="outline" className="text-xs">
|
||||
@{mention.slice(0, 8)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Related Task */}
|
||||
{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)}
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{/* Copy button — visible on hover */}
|
||||
<CopyButton
|
||||
value={message.content}
|
||||
className="absolute right-2 top-3 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { Message } from "@/types";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MessageItem } from "./message-item";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function MessageList({ messages, isLoading }: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to the newest message using a sentinel div + scrollIntoView
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<MessageSquare className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p>No messages yet</p>
|
||||
<p className="text-sm">Start the conversation</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-1">
|
||||
{messages.map((message) => (
|
||||
<MessageItem key={message.id} message={message} />
|
||||
))}
|
||||
{/* Sentinel div — scrollIntoView targets this to keep the newest message visible */}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import structlog
|
||||
|
||||
from roboco.exceptions import MergeConflictError
|
||||
from roboco.foundation.policy import lifecycle as spec_module
|
||||
from roboco.foundation.policy.batch import is_batch_umbrella
|
||||
from roboco.foundation.policy.batch import is_batch_root_subtask, is_batch_umbrella
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.foundation.policy.content.validators import reject_trivial
|
||||
from roboco.services.gateway.choreographer._protocol import actor_context_fields
|
||||
@@ -6708,7 +6708,14 @@ class Choreographer:
|
||||
main_pm_agent_id, root_task_id
|
||||
),
|
||||
)
|
||||
if t.parent_task_id is not None:
|
||||
# A MegaTask root-subtask IS parented (the umbrella) yet carries its
|
||||
# own project/branch/PR and behaves as a root for git/CEO purposes
|
||||
# (is_batch_root_subtask) — only a genuine non-root subtask is refused
|
||||
# here; the umbrella's exemption elsewhere (is_batch_umbrella) is a
|
||||
# different, non-overlapping shape (batch_id set, no parent).
|
||||
if t.parent_task_id is not None and not is_batch_root_subtask(
|
||||
batch_id=t.batch_id, parent_task_id=t.parent_task_id
|
||||
):
|
||||
return Envelope.invalid_state(
|
||||
message=(
|
||||
"main_pm complete only operates on root tasks (no parent_task_id)"
|
||||
|
||||
@@ -37,6 +37,7 @@ from roboco.enforcement import (
|
||||
)
|
||||
from roboco.events import Event, EventType, get_event_bus
|
||||
from roboco.foundation.policy.batch import (
|
||||
is_batch_root_subtask,
|
||||
is_batch_umbrella,
|
||||
is_branchless_coordination,
|
||||
is_valid_batch_shape,
|
||||
@@ -5320,8 +5321,14 @@ class TaskService(BaseService):
|
||||
)
|
||||
return None
|
||||
|
||||
# Only parent tasks can be escalated to CEO (not subtasks)
|
||||
if task.parent_task_id:
|
||||
# Only parent (root) tasks can be escalated to CEO (not subtasks) —
|
||||
# EXCEPT a MegaTask root-subtask, which IS parented (the umbrella) yet
|
||||
# carries its own project/branch/PR and behaves as a root for
|
||||
# git/CEO purposes (is_batch_root_subtask). A plain subtask (no
|
||||
# batch_id) is still refused.
|
||||
if task.parent_task_id and not is_batch_root_subtask(
|
||||
batch_id=task.batch_id, parent_task_id=task.parent_task_id
|
||||
):
|
||||
self.log.warning(
|
||||
"Cannot escalate subtask to CEO - only parent tasks allowed",
|
||||
task_id=str(task_id),
|
||||
|
||||
@@ -174,6 +174,33 @@ def dispatcher_assign(stack: E2EStack, task_id: Any, agent_id: Any) -> None:
|
||||
stack.run_db(_run)
|
||||
|
||||
|
||||
def set_branch_name(stack: E2EStack, task_id: Any, branch_name: str) -> None:
|
||||
"""Directly set a task's ``branch_name`` — a data field, not a lifecycle
|
||||
transition (mirrors ``dispatcher_assign``'s style: standing in for a
|
||||
system-side effect the real claim path would otherwise perform)."""
|
||||
from roboco.db.tables import TaskTable
|
||||
from sqlalchemy import select
|
||||
|
||||
async def _run(session: AsyncSession) -> None:
|
||||
row = (
|
||||
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
|
||||
).scalar_one()
|
||||
row.branch_name = branch_name
|
||||
|
||||
stack.run_db(_run)
|
||||
|
||||
|
||||
def wire_dependency(stack: E2EStack, dependent_id: Any, depends_on_id: Any) -> None:
|
||||
"""Wire a real dependency edge the same way production sequencing does
|
||||
(``TaskService.add_dependency``) — not a direct status write."""
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
async def _run(session: AsyncSession) -> None:
|
||||
await get_task_service(session).add_dependency(dependent_id, depends_on_id)
|
||||
|
||||
stack.run_db(_run)
|
||||
|
||||
|
||||
def origin_branch(stack: E2EStack, name: str, start: str = "master") -> None:
|
||||
"""Create + push a branch in the shared origin via the admin clone."""
|
||||
from tests.e2e_smoke.harness import _git
|
||||
@@ -535,6 +562,65 @@ def seed_hierarchy(
|
||||
}
|
||||
|
||||
|
||||
def seed_cell_and_dev(
|
||||
stack: E2EStack,
|
||||
company: Company,
|
||||
project_id: Any,
|
||||
root: dict[str, Any],
|
||||
*,
|
||||
filename: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Cell (cell-PM) → dev child under an ALREADY-EXISTING root, seeded
|
||||
mid-flight — the cell/child half of :func:`seed_hierarchy`, generalized
|
||||
for a caller that owns its own root (e.g. a MegaTask root-subtask whose
|
||||
root is claimed/seeded separately from this cell). ``root`` carries
|
||||
``root_id`` / ``root_branch`` (the shape :func:`seed_hierarchy` returns)."""
|
||||
from roboco.models.base import TaskStatus, TaskType
|
||||
|
||||
root_id, root_branch = root["root_id"], root["root_branch"]
|
||||
cell_id = uuid4()
|
||||
cell_branch = f"{root_branch}--{str(cell_id)[:8]}"
|
||||
origin_branch(stack, cell_branch, start=root_branch)
|
||||
seed_task(
|
||||
stack,
|
||||
id=cell_id,
|
||||
title=f"Backend slice: {filename}",
|
||||
description=(
|
||||
"Cell task assembling the backend slice of the feature; one dev "
|
||||
"leaf writes the file, the cell PM assembles and submits."
|
||||
),
|
||||
acceptance_criteria=[f"{filename} exists at the repo root"],
|
||||
task_type=TaskType.PLANNING,
|
||||
project_id=project_id,
|
||||
created_by=company.main_pm_id,
|
||||
assigned_to=company.cell_pm_id,
|
||||
parent_task_id=root_id,
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
branch_name=cell_branch,
|
||||
active_claimant_id=company.cell_pm_id,
|
||||
)
|
||||
child_id = seed_task(
|
||||
stack,
|
||||
title=f"Write {filename}",
|
||||
description=(
|
||||
f"Create {filename} with a friendly greeting at the repo root so "
|
||||
"the scenario has a real change to assemble upward."
|
||||
),
|
||||
acceptance_criteria=[f"{filename} exists at the repo root"],
|
||||
project_id=project_id,
|
||||
created_by=company.cell_pm_id,
|
||||
parent_task_id=cell_id,
|
||||
assigned_to=company.dev_id,
|
||||
)
|
||||
return {
|
||||
"root_id": root_id,
|
||||
"root_branch": root_branch,
|
||||
"cell_id": cell_id,
|
||||
"cell_branch": cell_branch,
|
||||
"child_id": child_id,
|
||||
}
|
||||
|
||||
|
||||
def reviewer_gate_pass_arc(stack: E2EStack, company: Company, task_id: Any) -> None:
|
||||
"""awaiting_pr_review → awaiting_pm_review via the in-path gate."""
|
||||
reviewer = ScriptedAgent(
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
"""Scenario 4: the MegaTask umbrella — sequencing hold, serial root-subtask
|
||||
completion, and the branchless umbrella close.
|
||||
|
||||
The umbrella (``batch_id`` set, ``parent_task_id=None``, no project/branch/PR
|
||||
of its own) groups two root-subtasks (``batch_id`` set, parented under the
|
||||
umbrella, each a real Main-PM delivery root with its own project/branch/PR —
|
||||
``is_batch_root_subtask``). RS2 depends on RS1 (the serial-wave-chain edge
|
||||
production wires via ``TaskService.add_dependency``). The scenario:
|
||||
|
||||
1. Proves the sequencing hold: RS2 cannot be planned while RS1 is open.
|
||||
2. Drives RS1 through the REAL dev→QA→doc→cell→root→CEO chain to master.
|
||||
3. Proves the hold clears once RS1 is terminal, then drives RS2 through the
|
||||
identical chain (a genuinely serial MegaTask — RS1's root PR merges to
|
||||
master before RS2 is ever claimed).
|
||||
4. Closes the umbrella itself via the branchless path: ``complete`` escalates
|
||||
it straight from ``in_progress`` (no ``submit_root`` — an umbrella
|
||||
assembles no PR of its own) to ``awaiting_ceo_approval``, and the CEO
|
||||
closes it with ``POST /ceo-approve`` (NOT ``approve-and-merge`` — there is
|
||||
no PR to merge).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from tests.e2e_smoke.arcs import (
|
||||
dev_arc,
|
||||
dispatcher_assign,
|
||||
doc_arc,
|
||||
origin_branch,
|
||||
origin_file,
|
||||
qa_arc,
|
||||
reviewer_gate_pass_arc,
|
||||
seed_cell_and_dev,
|
||||
seed_company,
|
||||
seed_project,
|
||||
seed_task,
|
||||
set_branch_name,
|
||||
task_state,
|
||||
wire_dependency,
|
||||
)
|
||||
from tests.e2e_smoke.harness import ScriptedAgent, expect_error, expect_ok
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tests.e2e_smoke.arcs import Company
|
||||
from tests.e2e_smoke.harness import E2EStack
|
||||
|
||||
|
||||
@dataclass
|
||||
class World:
|
||||
"""The constant per-test context, bundled so the seeding helpers below
|
||||
stay under the argument-count lint bar."""
|
||||
|
||||
stack: E2EStack
|
||||
company: Company
|
||||
project_id: Any
|
||||
project_slug: str
|
||||
|
||||
|
||||
# Pydantic's IWillPlanRequest.approach enforces >= 150 chars at the HTTP
|
||||
# boundary regardless of which internal gate would fire first, so every
|
||||
# i_will_plan call (including the one expected to be REJECTED by the
|
||||
# dependency guard) needs a compliant approach.
|
||||
_APPROACH = (
|
||||
"Serialize the two root-subtasks of this MegaTask: land RS1's cell work, "
|
||||
"submit its root PR, pass the in-path review gate, escalate it to the "
|
||||
"CEO, and merge to master before RS2 — whose branch depends on RS1's "
|
||||
"completion — is released to repeat the identical sequence on its own "
|
||||
"root branch against the same project's master."
|
||||
)
|
||||
|
||||
|
||||
def _seed_umbrella(stack: E2EStack, company: Company) -> tuple[Any, Any]:
|
||||
"""Branchless MegaTask umbrella: no project/product/cell-map, no branch,
|
||||
no PR of its own — ``is_batch_umbrella`` (batch_id set, no parent)."""
|
||||
from roboco.models import Team
|
||||
from roboco.models.base import TaskStatus, TaskType
|
||||
|
||||
batch_id = uuid4()
|
||||
umbrella_id = seed_task(
|
||||
stack,
|
||||
title="MegaTask: ship the greeting feature in two waves",
|
||||
description=(
|
||||
"Umbrella coordinating two sequenced root-subtasks that each "
|
||||
"land a greeting file on the same project's master branch, one "
|
||||
"after the other."
|
||||
),
|
||||
acceptance_criteria=["every root-subtask lands on master"],
|
||||
task_type=TaskType.PLANNING,
|
||||
team=Team.MAIN_PM,
|
||||
batch_id=batch_id,
|
||||
parent_task_id=None,
|
||||
created_by=company.main_pm_id,
|
||||
assigned_to=company.main_pm_id,
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
active_claimant_id=company.main_pm_id,
|
||||
)
|
||||
return umbrella_id, batch_id
|
||||
|
||||
|
||||
def _seed_root_subtask_in_progress(
|
||||
world: World, *, umbrella_id: Any, batch_id: Any, wave: int
|
||||
) -> dict[str, Any]:
|
||||
"""RS1: pre-seeded mid-flight exactly like ``seed_hierarchy``'s root,
|
||||
plus the ``batch_id``/``parent_task_id`` that make it a root-subtask."""
|
||||
from roboco.models import Team
|
||||
from roboco.models.base import TaskStatus, TaskType
|
||||
|
||||
stack, company = world.stack, world.company
|
||||
root_id = uuid4()
|
||||
root_branch = f"feature/main_pm/{str(root_id)[:8]}"
|
||||
origin_branch(stack, root_branch, start="master")
|
||||
seed_task(
|
||||
stack,
|
||||
id=root_id,
|
||||
title=f"Root-subtask {wave}: greeting wave {wave}",
|
||||
description=(
|
||||
f"MegaTask root-subtask (wave {wave}): assembles the backend "
|
||||
"cell's greeting file and lands it on this project's master via "
|
||||
"the normal root→CEO chain."
|
||||
),
|
||||
acceptance_criteria=["the greeting feature lands on the root branch"],
|
||||
task_type=TaskType.PLANNING,
|
||||
team=Team.MAIN_PM,
|
||||
batch_id=batch_id,
|
||||
parent_task_id=umbrella_id,
|
||||
project_id=world.project_id,
|
||||
created_by=company.main_pm_id,
|
||||
assigned_to=company.main_pm_id,
|
||||
status=TaskStatus.IN_PROGRESS,
|
||||
branch_name=root_branch,
|
||||
active_claimant_id=company.main_pm_id,
|
||||
)
|
||||
return {"root_id": root_id, "root_branch": root_branch}
|
||||
|
||||
|
||||
def _seed_root_subtask_pending(
|
||||
world: World, *, umbrella_id: Any, batch_id: Any, wave: int
|
||||
) -> Any:
|
||||
"""RS2: a real Main-PM root-subtask, PENDING (unclaimed, no branch yet —
|
||||
it is claimed for real once its dependency clears, exercising the claim
|
||||
path scenarios 1-3 skip by pre-seeding roots mid-flight)."""
|
||||
from roboco.models import Team
|
||||
from roboco.models.base import TaskStatus, TaskType
|
||||
|
||||
stack, company = world.stack, world.company
|
||||
root_id = uuid4()
|
||||
seed_task(
|
||||
stack,
|
||||
id=root_id,
|
||||
title=f"Root-subtask {wave}: greeting wave {wave}",
|
||||
description=(
|
||||
f"MegaTask root-subtask (wave {wave}): depends on the prior "
|
||||
"root-subtask (the serial wave-chain edge) and repeats the same "
|
||||
"root→CEO chain once the dependency clears."
|
||||
),
|
||||
acceptance_criteria=["the greeting feature lands on the root branch"],
|
||||
task_type=TaskType.PLANNING,
|
||||
team=Team.MAIN_PM,
|
||||
batch_id=batch_id,
|
||||
parent_task_id=umbrella_id,
|
||||
project_id=world.project_id,
|
||||
created_by=company.main_pm_id,
|
||||
assigned_to=company.main_pm_id,
|
||||
status=TaskStatus.PENDING,
|
||||
)
|
||||
return root_id
|
||||
|
||||
|
||||
def _land_and_merge_cell(
|
||||
stack: E2EStack,
|
||||
company: Company,
|
||||
project_slug: str,
|
||||
h: dict[str, Any],
|
||||
*,
|
||||
filename: str,
|
||||
) -> ScriptedAgent:
|
||||
"""dev → QA → docs → cell-PM completes the child (the child-landing half
|
||||
of ``test_pm_merge_chain._land_child``, parameterized by filename so two
|
||||
root-subtasks sharing one project never collide on the same additive
|
||||
file when both eventually merge into the same master)."""
|
||||
dev_arc(
|
||||
stack,
|
||||
company,
|
||||
project_slug,
|
||||
h["child_id"],
|
||||
work=(filename, f"Hello from {filename}!\n"),
|
||||
)
|
||||
qa_arc(stack, company, h["child_id"])
|
||||
doc_arc(stack, company, h["child_id"], filename=filename)
|
||||
|
||||
child = task_state(stack, h["child_id"])
|
||||
child_pr = stack.github.prs[child["pr_number"]]
|
||||
assert child_pr["base"]["ref"] == h["cell_branch"], (
|
||||
f"child PR should target the cell branch: {child_pr['base']} / {child}"
|
||||
)
|
||||
|
||||
pm = ScriptedAgent(stack, company.cell_pm_id, "be-pm", "cell_pm")
|
||||
expect_ok(
|
||||
pm.flow(
|
||||
"complete",
|
||||
task_id=str(h["child_id"]),
|
||||
notes=(
|
||||
"Child verified: QA passed with per-criterion verdicts and "
|
||||
"docs are complete; merging the leaf PR into the cell branch."
|
||||
),
|
||||
),
|
||||
"pm complete child",
|
||||
)
|
||||
assert task_state(stack, h["child_id"])["status"] == "completed"
|
||||
assert origin_file(stack, h["cell_branch"], filename), (
|
||||
"child merge did not land the file on the cell branch"
|
||||
)
|
||||
return pm
|
||||
|
||||
|
||||
def _merge_cell_to_root(
|
||||
stack: E2EStack,
|
||||
company: Company,
|
||||
pm: ScriptedAgent,
|
||||
h: dict[str, Any],
|
||||
*,
|
||||
filename: str,
|
||||
) -> None:
|
||||
"""Dispatcher re-claim (mirrored) + the PM's cell→root merge turn (the
|
||||
``test_pm_merge_chain._pm_merges_cell`` shape, parameterized by filename)."""
|
||||
dispatcher_assign(stack, h["cell_id"], company.cell_pm_id)
|
||||
expect_ok(
|
||||
pm.flow(
|
||||
"complete",
|
||||
task_id=str(h["cell_id"]),
|
||||
notes=(
|
||||
"Gate passed; merging the assembled cell PR into the root "
|
||||
"branch and closing the cell task."
|
||||
),
|
||||
),
|
||||
"pm complete cell",
|
||||
)
|
||||
assert task_state(stack, h["cell_id"])["status"] == "completed"
|
||||
assert origin_file(stack, h["root_branch"], filename), (
|
||||
"cell merge did not land the file on the root branch"
|
||||
)
|
||||
|
||||
|
||||
def _land_root_subtask(world: World, root: dict[str, Any], *, filename: str) -> None:
|
||||
"""Full cell→dev delivery under an already-claimed root-subtask."""
|
||||
stack, company, project_slug = world.stack, world.company, world.project_slug
|
||||
h = seed_cell_and_dev(stack, company, world.project_id, root, filename=filename)
|
||||
pm = _land_and_merge_cell(stack, company, project_slug, h, filename=filename)
|
||||
expect_ok(
|
||||
pm.flow(
|
||||
"submit_up",
|
||||
task_id=str(h["cell_id"]),
|
||||
notes=(
|
||||
"All children terminal and merged into the cell branch; "
|
||||
"assembling the cell PR for the in-path review gate."
|
||||
),
|
||||
),
|
||||
"pm submit_up",
|
||||
)
|
||||
reviewer_gate_pass_arc(stack, company, h["cell_id"])
|
||||
_merge_cell_to_root(stack, company, pm, h, filename=filename)
|
||||
|
||||
|
||||
def _submit_review_escalate_merge(
|
||||
stack: E2EStack, company: Company, main_pm: ScriptedAgent, root_id: Any
|
||||
) -> None:
|
||||
"""submit_root → reviewer gate → main_pm complete → CEO approve-and-merge.
|
||||
|
||||
The root analogue of ``test_root_ceo_chain``'s root→master chain, reused
|
||||
verbatim for each root-subtask (each opens + merges its OWN root→master
|
||||
PR — a MegaTask root-subtask behaves exactly like a plain Main-PM root
|
||||
for git/CEO purposes, see ``is_batch_root_subtask``)."""
|
||||
rid = str(root_id)
|
||||
expect_ok(
|
||||
main_pm.flow(
|
||||
"submit_root",
|
||||
task_id=rid,
|
||||
notes=(
|
||||
"Every cell task is terminal and assembled on the root "
|
||||
"branch; opening the root PR against master for the gate."
|
||||
),
|
||||
),
|
||||
"main_pm submit_root",
|
||||
)
|
||||
root_state = task_state(stack, root_id)
|
||||
assert root_state["status"] == "awaiting_pr_review", root_state
|
||||
assert root_state["pr_number"], root_state
|
||||
|
||||
reviewer_gate_pass_arc(stack, company, root_id)
|
||||
dispatcher_assign(stack, root_id, company.main_pm_id)
|
||||
expect_ok(
|
||||
main_pm.flow(
|
||||
"complete",
|
||||
task_id=rid,
|
||||
notes=(
|
||||
"Gate passed on the assembled root PR; approving the "
|
||||
"root-subtask and escalating to the CEO for the merge "
|
||||
"decision."
|
||||
),
|
||||
),
|
||||
"main_pm complete root-subtask",
|
||||
)
|
||||
assert task_state(stack, root_id)["status"] == "awaiting_ceo_approval"
|
||||
|
||||
resp = httpx.post(
|
||||
f"{stack.base_url}/api/tasks/{rid}/approve-and-merge",
|
||||
headers={"X-Agent-ID": str(company.ceo_id), "X-Agent-Role": "ceo"},
|
||||
timeout=60,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, (
|
||||
f"approve-and-merge: {resp.status_code} {resp.text[:1500]}"
|
||||
)
|
||||
assert task_state(stack, root_id)["status"] == "completed"
|
||||
|
||||
|
||||
def test_megatask_umbrella_sequenced_close(e2e_stack: E2EStack) -> None:
|
||||
stack = e2e_stack
|
||||
company = seed_company(stack)
|
||||
project_id, project_slug = seed_project(stack, company)
|
||||
world = World(stack, company, project_id, project_slug)
|
||||
main_pm = ScriptedAgent(stack, company.main_pm_id, "main-pm", "main_pm")
|
||||
|
||||
umbrella_id, batch_id = _seed_umbrella(stack, company)
|
||||
rs1 = _seed_root_subtask_in_progress(
|
||||
world, umbrella_id=umbrella_id, batch_id=batch_id, wave=1
|
||||
)
|
||||
rs2_id = _seed_root_subtask_pending(
|
||||
world, umbrella_id=umbrella_id, batch_id=batch_id, wave=2
|
||||
)
|
||||
# The serial wave-chain edge: RS2 waits for RS1, wired the same way
|
||||
# production sequencing does (TaskService.add_dependency), not a status
|
||||
# write.
|
||||
wire_dependency(stack, rs2_id, rs1["root_id"])
|
||||
|
||||
# --- 1. SEQUENCING HOLD: RS2 cannot be planned while RS1 is open --------
|
||||
expect_error(
|
||||
main_pm.flow(
|
||||
"i_will_plan",
|
||||
task_id=str(rs2_id),
|
||||
plan="Land wave 2 once wave 1 is terminal.",
|
||||
approach=_APPROACH,
|
||||
sub_tasks=[],
|
||||
),
|
||||
"invalid_state",
|
||||
"main_pm i_will_plan RS2 while RS1 open",
|
||||
)
|
||||
assert task_state(stack, rs2_id)["status"] == "pending"
|
||||
|
||||
# --- 2. RS1: the REAL dev→QA→doc→cell→root→CEO chain to master ---------
|
||||
_land_root_subtask(world, rs1, filename="rs1.txt")
|
||||
_submit_review_escalate_merge(stack, company, main_pm, rs1["root_id"])
|
||||
assert origin_file(stack, "master", "rs1.txt"), "RS1 did not land on master"
|
||||
|
||||
# --- 3. Hold clears: RS2 can now be planned (real claim + branch cut) --
|
||||
rs2_branch = f"feature/main_pm/{str(rs2_id)[:8]}"
|
||||
# Cut RS2's branch from the NOW-current master (post-RS1-merge) so the
|
||||
# claim needs no behind-base auto-sync — mirrors how a wave-2
|
||||
# root-subtask's branch is only meaningful once wave 1 has landed.
|
||||
origin_branch(stack, rs2_branch, start="master")
|
||||
set_branch_name(stack, rs2_id, rs2_branch)
|
||||
|
||||
def _plan_rs2() -> dict[str, Any]:
|
||||
return main_pm.flow(
|
||||
"i_will_plan",
|
||||
task_id=str(rs2_id),
|
||||
plan="RS1 is terminal; land wave 2 now.",
|
||||
approach=_APPROACH,
|
||||
sub_tasks=[
|
||||
{
|
||||
"title": "Assemble the backend cell",
|
||||
"description": (
|
||||
"Delegate the backend cell task that writes rs2.txt, "
|
||||
"land it on the root branch, then submit the root PR "
|
||||
"for the in-path review gate."
|
||||
),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Real choreography (mirrors dev_arc's claim-time note): the composed
|
||||
# claim succeeds and stays; the post-claim tracing gate demands the
|
||||
# claim-time journal:decision; the retry short-circuits as re-entry.
|
||||
expect_error(_plan_rs2(), "tracing_gap", "main_pm i_will_plan RS2 first attempt")
|
||||
expect_ok(
|
||||
main_pm.do(
|
||||
"note",
|
||||
scope="decision",
|
||||
task_id=str(rs2_id),
|
||||
text=(
|
||||
"Wave 1 (RS1) is completed and merged to master; wave 2 "
|
||||
"(RS2) repeats the identical backend-cell delegation on its "
|
||||
"own root branch against the same project."
|
||||
),
|
||||
),
|
||||
"main_pm decision note at claim",
|
||||
)
|
||||
expect_ok(
|
||||
_plan_rs2(),
|
||||
"main_pm i_will_plan RS2 after RS1 terminal",
|
||||
)
|
||||
rs2_state = task_state(stack, rs2_id)
|
||||
assert rs2_state["status"] == "in_progress", rs2_state
|
||||
assert rs2_state["branch_name"] == rs2_branch, rs2_state
|
||||
|
||||
rs2 = {"root_id": rs2_id, "root_branch": rs2_branch}
|
||||
_land_root_subtask(world, rs2, filename="rs2.txt")
|
||||
_submit_review_escalate_merge(stack, company, main_pm, rs2_id)
|
||||
assert origin_file(stack, "master", "rs2.txt"), "RS2 did not land on master"
|
||||
|
||||
# --- 4. UMBRELLA CLOSE: branchless complete → escalate → ceo-approve ---
|
||||
expect_ok(
|
||||
main_pm.flow(
|
||||
"complete",
|
||||
task_id=str(umbrella_id),
|
||||
notes=(
|
||||
"Both root-subtasks are completed and merged to master; "
|
||||
"closing the MegaTask umbrella and escalating for CEO "
|
||||
"sign-off."
|
||||
),
|
||||
),
|
||||
"main_pm complete umbrella",
|
||||
)
|
||||
escalated = task_state(stack, umbrella_id)
|
||||
assert escalated["status"] == "awaiting_ceo_approval", escalated
|
||||
assert escalated["pr_number"] is None, escalated
|
||||
|
||||
resp = httpx.post(
|
||||
f"{stack.base_url}/api/tasks/{umbrella_id}/ceo-approve",
|
||||
headers={"X-Agent-ID": str(company.ceo_id), "X-Agent-Role": "ceo"},
|
||||
json={"notes": ("Both root-subtasks landed on master; MegaTask complete.")},
|
||||
timeout=60,
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK, (
|
||||
f"ceo-approve: {resp.status_code} {resp.text[:1500]}"
|
||||
)
|
||||
final = task_state(stack, umbrella_id)
|
||||
assert final["status"] == "completed", final
|
||||
assert final["pr_number"] is None, final
|
||||
assert origin_file(stack, "master", "rs1.txt")
|
||||
assert origin_file(stack, "master", "rs2.txt")
|
||||
@@ -1367,6 +1367,27 @@ async def test_escalate_to_ceo_returns_none_for_subtask(
|
||||
assert out is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_to_ceo_allows_batch_root_subtask(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""A MegaTask root-subtask IS parented (the umbrella) yet carries its own
|
||||
project/branch/PR and behaves as a root for git/CEO purposes — unlike a
|
||||
plain subtask, it must NOT be refused by the parent-task-only check."""
|
||||
svc = task_setup["svc"]
|
||||
umbrella = await svc.create(_req(task_setup))
|
||||
sub = await svc.create(_req(task_setup, parent_task_id=umbrella.id))
|
||||
sub.status = TaskStatus.AWAITING_PM_REVIEW
|
||||
sub.batch_id = uuid4()
|
||||
sub.pr_number = 7
|
||||
await db_session.flush()
|
||||
escalated = await svc.escalate_to_ceo(
|
||||
sub.id, agent_role="main_pm", notes="root-subtask ready for CEO sign-off"
|
||||
)
|
||||
assert escalated is not None
|
||||
assert escalated.status == TaskStatus.AWAITING_CEO_APPROVAL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalate_to_ceo_returns_none_when_no_pr(
|
||||
task_setup: dict, db_session: AsyncSession
|
||||
|
||||
@@ -704,6 +704,7 @@ async def test_main_pm_complete_rejects_non_root_task() -> None:
|
||||
status="awaiting_pm_review",
|
||||
assigned_to=main_pm_id,
|
||||
parent_task_id=uuid4(), # has parent -> not a root task
|
||||
batch_id=None, # a plain subtask, NOT a MegaTask root-subtask
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
@@ -716,6 +717,46 @@ async def test_main_pm_complete_rejects_non_root_task() -> None:
|
||||
assert "root tasks" in body["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_main_pm_complete_allows_batch_root_subtask() -> None:
|
||||
"""A MegaTask root-subtask IS parented (the umbrella) yet carries its own
|
||||
project/branch/PR and behaves as a root for git/CEO purposes — the
|
||||
parent_task_id refusal above must NOT fire for it (is_batch_root_subtask)."""
|
||||
main_pm_id = uuid4()
|
||||
root_task_id = uuid4()
|
||||
t = MagicMock(
|
||||
id=root_task_id,
|
||||
status="awaiting_pm_review",
|
||||
assigned_to=main_pm_id,
|
||||
pr_number=42,
|
||||
branch_name="feature/main_pm/rootsub1",
|
||||
parent_task_id=uuid4(), # the umbrella
|
||||
batch_id=uuid4(), # batch_id + parent_task_id -> is_batch_root_subtask
|
||||
team="main_pm",
|
||||
)
|
||||
after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.escalate_to_ceo.return_value = after
|
||||
task_svc.all_subtasks_terminal.return_value = True
|
||||
task_svc.uncovered_parent_acceptance_criteria.return_value = []
|
||||
git_svc = AsyncMock()
|
||||
git_svc.pr_target.return_value = "master"
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_decision_for_task.return_value = True
|
||||
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||
journal_svc.has_reflect_for_task.return_value = True
|
||||
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.main_pm_complete(
|
||||
main_pm_id, root_task_id, notes="root-subtask reviewed and ready"
|
||||
)
|
||||
assert env.error is None, env.as_dict()
|
||||
assert env.status == "awaiting_ceo_approval"
|
||||
task_svc.escalate_to_ceo.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_main_pm_complete_blocks_unfinished_subtasks() -> None:
|
||||
main_pm_id = uuid4()
|
||||
|
||||
Reference in New Issue
Block a user