feat(board): materialize program items as Main-PM roots, make reports actionable (#711)

Two coupled gaps in the Board Program output path.

Approved items were created unowned and in BACKLOG. Nothing dispatches
BACKLOG, and once activated a cell PM claimed the parentless task as a root,
where _cell_pm_complete resolves its merge target through
resolve_parent_branch — which for a parentless task falls through to the
project head rung. The result was a cell branch merging straight into the
trunk, bypassing the Main-PM root, the root->master PR and the CEO gate
(live: PRs #703 and #704 both targeted slave directly).

All eight materializers now create a PENDING, main-pm-assigned root with
team=Team.MAIN_PM, matching what approve_and_start does for an intake draft.
The team is load-bearing, not cosmetic: _next_hint_pr_fail,
_deliver_pr_fail_to_owner, delegate's wave-chain dispatch and the PR layer
label all key on it, and a cell-teamed root drops the 'do NOT re-submit the
root' steer that exists because of PR #138's infinite pr_fail loop. The
item's own cell survives as a delegation hint in the description, which is
what the Main PM's briefing renders.

Periscope, Sentinel and Coroner produced artifacts with no way to act on
them — three panel surfaces carried explicit 'no approve/reject UI' comments
while each item already held a machine-readable suggested action. They now
have per-item approve and dismiss, modelled on the roadmap queue: idempotent
per item, CEO-gated, deep-copy-before-mutate so SQLAlchemy's dirty check
still fires, and every decision recorded through record_decision so it
reaches the next cycle's prompt. Approving materializes through the same
corrected Main-PM-owned path.

Target project resolves to each engine's own existing anchor — RoboCo's
project for Periscope and Sentinel, the incident's project for Coroner — and
fails with a clean invalid_state naming what is unresolvable rather than
guessing at a repo.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-26 20:01:24 +02:00
committed by GitHub
co-authored by Renn F
parent 66f0287d11
commit a7b970a3b2
48 changed files with 4412 additions and 377 deletions
@@ -1,15 +1,26 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { Postmortem } from "@/lib/api/coroner";
const { listPostmortems } = vi.hoisted(() => ({
const { listPostmortems, approveProcessChange, rejectProcessChange } =
vi.hoisted(() => ({
listPostmortems: vi.fn(async () => [] as Postmortem[]),
}));
approveProcessChange: vi.fn(async () => ({
status: "approved",
materialized_task_id: "task-1",
detail: "materialized as a Main-PM-owned task",
})),
rejectProcessChange: vi.fn(async () => ({
status: "rejected",
detail: "dismissed; feeds the next cycle's prompt",
})),
}));
vi.mock("@/lib/api", () => ({
coronerApi: { listPostmortems },
coronerApi: { listPostmortems, approveProcessChange, rejectProcessChange },
}));
import { CoronerPostmortemsCard } from "../coroner-postmortems-card";
@@ -21,7 +32,8 @@ function withQueryClient(ui: ReactNode) {
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
const SAMPLE_POSTMORTEM: Postmortem = {
function buildPostmortem(overrides: Partial<Postmortem> = {}): Postmortem {
return {
task_id: "pm-1",
title: "Coroner postmortem",
completed_at: "2026-07-24T10:00:00Z",
@@ -34,11 +46,18 @@ const SAMPLE_POSTMORTEM: Postmortem = {
process_change_kind: "conventions_rule",
process_change_description: "Add a venv-freshness check to make quality.",
playbook_id: null,
};
process_change_status: "proposed",
process_change_reject_reason: null,
process_change_materialized_task_id: null,
...overrides,
};
}
const SAMPLE_POSTMORTEM: Postmortem = buildPostmortem();
describe("CoronerPostmortemsCard", () => {
beforeEach(() => {
listPostmortems.mockClear();
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
@@ -61,15 +80,11 @@ describe("CoronerPostmortemsCard", () => {
expect(screen.getByText("bounced 3+ times")).toBeInTheDocument();
expect(screen.getByText("awaiting_qa")).toBeInTheDocument();
expect(screen.getByText("conventions_rule")).toBeInTheDocument();
expect(
screen.getByText(/stale venv symptom/i),
).toBeInTheDocument();
expect(screen.getByText(/stale venv symptom/i)).toBeInTheDocument();
expect(
screen.getByText(/never verified the venv's dev extras/i),
).toBeInTheDocument();
expect(
screen.getByText(/add a venv-freshness check/i),
).toBeInTheDocument();
expect(screen.getByText(/add a venv-freshness check/i)).toBeInTheDocument();
});
it("shows an offline state and retries on error", async () => {
@@ -82,4 +97,70 @@ describe("CoronerPostmortemsCard", () => {
).toBeInTheDocument(),
);
});
// Defect 2 fix: a proposed process change now gets its own approve/dismiss
// action.
it("renders approve/dismiss controls for a proposed process change", async () => {
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
render(withQueryClient(<CoronerPostmortemsCard />));
await screen.findByText("Fix worktree venv rot");
expect(
screen.getByRole("button", { name: /approve/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /dismiss/i }),
).toBeInTheDocument();
});
it("hides the actions once the process change already drafted a playbook", async () => {
listPostmortems.mockResolvedValueOnce([
buildPostmortem({
process_change_kind: "playbook",
process_change_status: "not_applicable",
}),
]);
render(withQueryClient(<CoronerPostmortemsCard />));
await screen.findByText("Fix worktree venv rot");
expect(
screen.queryByRole("button", { name: /approve/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Drafted as playbook")).toBeInTheDocument();
});
it("approves the process change via the API", async () => {
const user = userEvent.setup();
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
render(withQueryClient(<CoronerPostmortemsCard />));
await screen.findByText("Fix worktree venv rot");
await user.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() =>
expect(approveProcessChange).toHaveBeenCalledWith("pm-1"),
);
});
it("dismisses the process change with a reason via the dialog", async () => {
const user = userEvent.setup();
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
render(withQueryClient(<CoronerPostmortemsCard />));
await screen.findByText("Fix worktree venv rot");
await user.click(screen.getByRole("button", { name: /dismiss/i }));
const dialog = await screen.findByRole("dialog");
await user.type(screen.getByLabelText("Reason"), "one-off incident");
await user.click(
within(dialog).getByRole("button", { name: /^dismiss$/i }),
);
await waitFor(() =>
expect(rejectProcessChange).toHaveBeenCalledWith(
"pm-1",
"one-off incident",
),
);
});
});
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { MarketBrief } from "@/lib/api/periscope";
@@ -20,6 +21,7 @@ function buildBrief(overrides: Partial<MarketBrief> = {}): MarketBrief {
claim: "Competitor X launched an autonomous PR-review agent",
source_url: "https://example.com/competitor-x-launch",
relevance: "Directly overlaps our pr_reviewer role",
status: "proposed",
},
],
threats: ["Feature parity gap on PR review"],
@@ -29,17 +31,28 @@ function buildBrief(overrides: Partial<MarketBrief> = {}): MarketBrief {
};
}
const { listBriefs } = vi.hoisted(() => ({
const { listBriefs, approveFinding, rejectFinding } = vi.hoisted(() => ({
listBriefs: vi.fn(
() =>
new Promise((r) => {
resolveListRef.current = r as (v: unknown) => void;
}),
),
approveFinding: vi.fn(async () => ({
status: "approved",
finding_id: "finding-0",
materialized_task_id: "task-1",
detail: "materialized as a Main-PM-owned task",
})),
rejectFinding: vi.fn(async () => ({
status: "rejected",
finding_id: "finding-0",
detail: "dismissed; feeds the next cycle's prompt",
})),
}));
vi.mock("@/lib/api", () => ({
periscopeApi: { listBriefs },
periscopeApi: { listBriefs, approveFinding, rejectFinding },
}));
import { MarketBriefsTab } from "../market-briefs-tab";
@@ -90,28 +103,94 @@ describe("MarketBriefsTab", () => {
"href",
"https://example.com/competitor-x-launch",
);
expect(screen.getByText("Feature parity gap on PR review")).toBeInTheDocument();
expect(
screen.getByText("Feature parity gap on PR review"),
).toBeInTheDocument();
expect(
screen.getByText("Lean into our findings-ledger differentiator"),
).toBeInTheDocument();
expect(
screen.getByText(
"Emphasize the structured findings ledger in messaging",
),
screen.getByText("Emphasize the structured findings ledger in messaging"),
).toBeInTheDocument();
});
it("renders no approve/reject controls — a report has no queue action", async () => {
// Defect 2 fix: a proposed finding now gets its own approve/dismiss action
// even though the brief itself stays a read-only report — was "renders no
// approve/reject controls".
it("renders per-finding approve/dismiss controls for a proposed finding", async () => {
render(withQueryClient(<MarketBriefsTab />));
resolveListRef.current?.([buildBrief()]);
await screen.findByText("A rival tool shipped agentic PR review");
expect(
screen.getByRole("button", { name: /approve/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /dismiss/i }),
).toBeInTheDocument();
});
it("hides the actions once a finding is approved", async () => {
render(withQueryClient(<MarketBriefsTab />));
resolveListRef.current?.([
buildBrief({
findings: [
{
id: "finding-0",
claim: "Competitor X launched an autonomous PR-review agent",
source_url: "https://example.com/competitor-x-launch",
relevance: "Directly overlaps our pr_reviewer role",
status: "approved",
materialized_task_id: "task-1",
},
],
}),
]);
await screen.findByText("A rival tool shipped agentic PR review");
expect(
screen.queryByRole("button", { name: /approve/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /reject/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Approved")).toBeInTheDocument();
});
it("approves a finding via the API", async () => {
const user = userEvent.setup();
render(withQueryClient(<MarketBriefsTab />));
resolveListRef.current?.([buildBrief()]);
await screen.findByText("A rival tool shipped agentic PR review");
await user.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() =>
expect(approveFinding).toHaveBeenCalledWith("brief-1", "finding-0"),
);
});
it("dismisses a finding with a reason via the dialog", async () => {
const user = userEvent.setup();
render(withQueryClient(<MarketBriefsTab />));
resolveListRef.current?.([buildBrief()]);
await screen.findByText("A rival tool shipped agentic PR review");
await user.click(screen.getByRole("button", { name: /dismiss/i }));
const dialog = await screen.findByRole("dialog");
await user.type(
screen.getByLabelText("Reason"),
"not actionable this quarter",
);
await user.click(
within(dialog).getByRole("button", { name: /^dismiss$/i }),
);
await waitFor(() =>
expect(rejectFinding).toHaveBeenCalledWith(
"brief-1",
"finding-0",
"not actionable this quarter",
),
);
});
it("omits optional sections when threats/opportunities/positioning are absent", async () => {
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { QualityReport } from "@/lib/api/sentinel";
@@ -18,27 +19,41 @@ function buildReport(overrides: Partial<QualityReport> = {}): QualityReport {
{
id: "item-0",
area: "waivers",
observation: "Minor findings in roboco/services/task.py keep getting waived",
observation:
"Minor findings in roboco/services/task.py keep getting waived",
evidence: "5 waived-minor findings this week (prior week: 1)",
suggested_action: "Convert to a Pest Control bug task",
status: "proposed",
},
],
overall_assessment: "Drift is concentrated in one hotspot file, not systemic yet",
overall_assessment:
"Drift is concentrated in one hotspot file, not systemic yet",
...overrides,
};
}
const { listReports } = vi.hoisted(() => ({
const { listReports, approveItem, rejectItem } = vi.hoisted(() => ({
listReports: vi.fn(
() =>
new Promise((r) => {
resolveListRef.current = r as (v: unknown) => void;
}),
),
approveItem: vi.fn(async () => ({
status: "approved",
item_id: "item-0",
materialized_task_id: "task-1",
detail: "materialized as a Main-PM-owned task",
})),
rejectItem: vi.fn(async () => ({
status: "rejected",
item_id: "item-0",
detail: "dismissed; feeds the next cycle's prompt",
})),
}));
vi.mock("@/lib/api", () => ({
sentinelApi: { listReports },
sentinelApi: { listReports, approveItem, rejectItem },
}));
import { QualityReportsTab } from "../quality-reports-tab";
@@ -100,17 +115,81 @@ describe("QualityReportsTab", () => {
).toBeInTheDocument();
});
it("renders no approve/reject controls — a report has no queue action", async () => {
// Defect 2 fix: a proposed drift item now gets its own approve/dismiss
// action even though the report itself stays a read-only report — was
// "renders no approve/reject controls".
it("renders per-item approve/dismiss controls for a proposed item", async () => {
render(withQueryClient(<QualityReportsTab />));
resolveListRef.current?.([buildReport()]);
await screen.findByText("Waived findings climbed 3x this week");
expect(
screen.getByRole("button", { name: /approve/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /dismiss/i }),
).toBeInTheDocument();
});
it("hides the actions once an item is approved", async () => {
render(withQueryClient(<QualityReportsTab />));
resolveListRef.current?.([
buildReport({
items: [
{
id: "item-0",
area: "waivers",
observation: "Minor findings keep getting waived",
evidence: "5 waived this week",
suggested_action: "Convert to a Pest Control bug task",
status: "approved",
materialized_task_id: "task-1",
},
],
}),
]);
await screen.findByText("Waived findings climbed 3x this week");
expect(
screen.queryByRole("button", { name: /approve/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /reject/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Approved")).toBeInTheDocument();
});
it("approves an item via the API", async () => {
const user = userEvent.setup();
render(withQueryClient(<QualityReportsTab />));
resolveListRef.current?.([buildReport()]);
await screen.findByText("Waived findings climbed 3x this week");
await user.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() =>
expect(approveItem).toHaveBeenCalledWith("report-1", "item-0"),
);
});
it("dismisses an item with a reason via the dialog", async () => {
const user = userEvent.setup();
render(withQueryClient(<QualityReportsTab />));
resolveListRef.current?.([buildReport()]);
await screen.findByText("Waived findings climbed 3x this week");
await user.click(screen.getByRole("button", { name: /dismiss/i }));
const dialog = await screen.findByRole("dialog");
await user.type(screen.getByLabelText("Reason"), "already tracked");
await user.click(
within(dialog).getByRole("button", { name: /^dismiss$/i }),
);
await waitFor(() =>
expect(rejectItem).toHaveBeenCalledWith(
"report-1",
"item-0",
"already tracked",
),
);
});
it("omits the overall-assessment section when absent", async () => {
@@ -1,6 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { coronerApi } from "@/lib/api";
import type { Postmortem } from "@/lib/api/coroner";
import {
@@ -11,10 +12,24 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
import { Stethoscope } from "lucide-react";
import { CheckCircle2, Stethoscope, XCircle } from "lucide-react";
import { toast } from "sonner";
const _MIN_REASON_CHARS = 4;
const INCIDENT_KIND_LABELS: Record<string, string> = {
bounced: "bounced 3+ times",
@@ -22,7 +37,46 @@ const INCIDENT_KIND_LABELS: Record<string, string> = {
budget: "budget-blocked",
};
function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
function processChangeStatusBadge(postmortem: Postmortem) {
if (postmortem.process_change_status === "approved") {
return (
<HelpTip label="Materialized as a Main-PM-owned task">
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
Approved
</Badge>
</HelpTip>
);
}
if (postmortem.process_change_status === "rejected") {
return (
<HelpTip label="Dismissed — not added to the backlog">
<Badge variant="outline">Dismissed</Badge>
</HelpTip>
);
}
if (postmortem.process_change_status === "not_applicable") {
return (
<HelpTip label="Already drafted straight into the playbook review queue — nothing else to decide">
<Badge variant="outline">Drafted as playbook</Badge>
</HelpTip>
);
}
return null;
}
function PostmortemRow({
postmortem,
onApprove,
onReject,
approving,
}: {
postmortem: Postmortem;
onApprove: (taskId: string) => void;
onReject: (postmortem: Postmortem) => void;
approving: boolean;
}) {
const isProposed = postmortem.process_change_status === "proposed";
return (
<div className="rounded-lg border p-4 space-y-2">
<div className="flex flex-wrap items-center gap-2">
@@ -47,6 +101,7 @@ function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
<Badge>{postmortem.process_change_kind}</Badge>
</HelpTip>
)}
{processChangeStatusBadge(postmortem)}
</div>
{postmortem.incident_summary && (
<p className="text-sm text-muted-foreground">
@@ -65,20 +120,58 @@ function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
{postmortem.process_change_description}
</p>
)}
{postmortem.process_change_status === "rejected" &&
postmortem.process_change_reject_reason && (
<p className="text-sm text-destructive">
Dismissed: {postmortem.process_change_reject_reason}
</p>
)}
{postmortem.completed_at && (
<p className="text-xs text-muted-foreground">
{new Date(postmortem.completed_at).toLocaleString()}
</p>
)}
{isProposed && (
<div className="flex justify-end gap-2 pt-1">
<HelpTip label="Records your reason — not added to the backlog">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onReject(postmortem)}
>
<XCircle className="mr-1 h-3.5 w-3.5" />
Dismiss
</Button>
</HelpTip>
<HelpTip label="Materializes this process change as a Main-PM-owned task — needs normal PM activation to start">
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving}
onClick={() => onApprove(postmortem.task_id)}
>
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Approve
</Button>
</HelpTip>
</div>
)}
</div>
);
}
// Read-only Postmortems list — a Coroner postmortem completes atomically at
// propose_postmortem time (no per-item approve/reject like Pest Control/
// Roadmap), so unlike those review queues this never hides on empty; it's a
// retrospective report the CEO checks, not an urgent action queue.
// Postmortems list — a Coroner postmortem completes atomically at
// propose_postmortem time (no per-item approve/reject at the TASK level
// like Pest Control/Roadmap), so this never hides on empty; it's a
// retrospective report the CEO checks. Its single process change still
// carries its own per-item approve/dismiss action, below each row.
export function CoronerPostmortemsCard({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<Postmortem | null>(null);
const [reason, setReason] = useState("");
const [approvingTaskId, setApprovingTaskId] = useState<string | null>(null);
const {
data: postmortems,
isLoading,
@@ -90,7 +183,64 @@ export function CoronerPostmortemsCard({ className }: { className?: string }) {
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["coroner", "postmortems"] });
const approveMutation = useMutation({
mutationFn: (taskId: string) => coronerApi.approveProcessChange(taskId),
onSuccess: (result) => {
invalidate();
if (
result.status === "approved" ||
result.status === "already_approved"
) {
toast.success("Process change approved — materialized as a task");
} else {
toast.warning(result.detail);
}
},
onError: (e) =>
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingTaskId(null),
});
const rejectMutation = useMutation({
mutationFn: ({ taskId, reason }: { taskId: string; reason: string }) =>
coronerApi.rejectProcessChange(taskId, reason),
onSuccess: () => {
invalidate();
toast.success("Process change dismissed");
closeReject();
},
onError: (e) =>
toast.error(
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON_CHARS) {
toast.error("Give a brief reason for dismissing");
return;
}
rejectMutation.mutate({ taskId: rejecting.task_id, reason: reason.trim() });
};
const handleApprove = (taskId: string) => {
setApprovingTaskId(taskId);
approveMutation.mutate(taskId);
};
return (
<>
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -124,11 +274,52 @@ export function CoronerPostmortemsCard({ className }: { className?: string }) {
) : (
<div className="space-y-3">
{postmortems.map((pm) => (
<PostmortemRow key={pm.task_id} postmortem={pm} />
<PostmortemRow
key={pm.task_id}
postmortem={pm}
onApprove={handleApprove}
onReject={setRejecting}
approving={approvingTaskId === pm.task_id}
/>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dismiss process change</DialogTitle>
<DialogDescription>
This records your reason and feeds the next cycle&apos;s prompt
it is not added to the backlog.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="coroner-reject-reason">Reason</Label>
<Textarea
id="coroner-reject-reason"
placeholder="e.g. one-off incident; not worth a standing process change..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,13 +1,34 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { periscopeApi } from "@/lib/api";
import type { MarketBrief } from "@/lib/api/periscope";
import type { MarketBrief, MarketBriefFinding } from "@/lib/api/periscope";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { CheckCircle2, XCircle } from "lucide-react";
import { toast } from "sonner";
const _MIN_REASON_CHARS = 4;
interface RejectTarget {
taskId: string;
finding: MarketBriefFinding;
}
// ---------------------------------------------------------------------------
// Read-only skeleton placeholder shaped like a brief card
@@ -28,12 +49,115 @@ function BriefCardSkeleton() {
);
}
function findingStatusBadge(finding: MarketBriefFinding) {
if (finding.status === "approved") {
return (
<HelpTip label="Materialized as a Main-PM-owned task">
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
Approved
</Badge>
</HelpTip>
);
}
if (finding.status === "rejected") {
return (
<HelpTip label="Dismissed — not added to the backlog">
<Badge variant="outline">Dismissed</Badge>
</HelpTip>
);
}
return null;
}
// ---------------------------------------------------------------------------
// One brief, read-only — headline/findings/sources/threats/opportunities.
// No approve/reject UI: a market brief is a report, not a queue item.
// One finding — claim/relevance/source, plus a per-finding approve/dismiss
// action (a market signal is worth acting on individually, even though the
// brief itself is a report).
// ---------------------------------------------------------------------------
function BriefCard({ brief }: { brief: MarketBrief }) {
function FindingRow({
taskId,
finding,
onApprove,
onReject,
approving,
}: {
taskId: string;
finding: MarketBriefFinding;
onApprove: (taskId: string, findingId: string) => void;
onReject: (target: RejectTarget) => void;
approving: boolean;
}) {
const isProposed = finding.status === "proposed";
return (
<li className="space-y-1 border-b pb-3 last:border-b-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2">
<p className="whitespace-pre-wrap text-sm">{finding.claim}</p>
{findingStatusBadge(finding)}
</div>
<p className="text-xs text-muted-foreground">
{finding.relevance} {" "}
<a
href={finding.source_url}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
source
</a>
</p>
{finding.status === "rejected" && finding.reject_reason && (
<p className="text-xs text-destructive">
Dismissed: {finding.reject_reason}
</p>
)}
{isProposed && (
<div className="flex justify-end gap-2 pt-1">
<HelpTip label="Records your reason — not added to the backlog">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onReject({ taskId, finding })}
>
<XCircle className="mr-1 h-3.5 w-3.5" />
Dismiss
</Button>
</HelpTip>
<HelpTip label="Materializes this finding as a Main-PM-owned task — needs normal PM activation to start">
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving}
onClick={() => onApprove(taskId, finding.id)}
>
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Approve
</Button>
</HelpTip>
</div>
)}
</li>
);
}
// ---------------------------------------------------------------------------
// One brief — headline/findings/sources/threats/opportunities, each finding
// with its own approve/dismiss action.
// ---------------------------------------------------------------------------
function BriefCard({
brief,
onApprove,
onReject,
approvingFindingId,
}: {
brief: MarketBrief;
onApprove: (taskId: string, findingId: string) => void;
onReject: (target: RejectTarget) => void;
approvingFindingId: string | null;
}) {
return (
<Card>
<CardHeader>
@@ -52,20 +176,14 @@ function BriefCard({ brief }: { brief: MarketBrief }) {
</p>
<ul className="space-y-2">
{brief.findings.map((f) => (
<li key={f.id} className="text-sm">
<p className="whitespace-pre-wrap">{f.claim}</p>
<p className="text-xs text-muted-foreground">
{f.relevance} {" "}
<a
href={f.source_url}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
source
</a>
</p>
</li>
<FindingRow
key={f.id}
taskId={brief.task_id}
finding={f}
onApprove={onApprove}
onReject={onReject}
approving={approvingFindingId === f.id}
/>
))}
</ul>
</div>
@@ -125,6 +243,13 @@ function BriefCard({ brief }: { brief: MarketBrief }) {
// ---------------------------------------------------------------------------
export function MarketBriefsTab() {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<RejectTarget | null>(null);
const [reason, setReason] = useState("");
const [approvingFindingId, setApprovingFindingId] = useState<string | null>(
null,
);
const {
data: briefs = [],
isLoading,
@@ -136,7 +261,81 @@ export function MarketBriefsTab() {
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["periscope", "briefs"] });
const approveMutation = useMutation({
mutationFn: ({
taskId,
findingId,
}: {
taskId: string;
findingId: string;
}) => periscopeApi.approveFinding(taskId, findingId),
onSuccess: (result) => {
invalidate();
if (
result.status === "approved" ||
result.status === "already_approved"
) {
toast.success("Finding approved — materialized as a task");
} else {
toast.warning(result.detail);
}
},
onError: (e) =>
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingFindingId(null),
});
const rejectMutation = useMutation({
mutationFn: ({
taskId,
findingId,
reason,
}: {
taskId: string;
findingId: string;
reason: string;
}) => periscopeApi.rejectFinding(taskId, findingId, reason),
onSuccess: () => {
invalidate();
toast.success("Finding dismissed");
closeReject();
},
onError: (e) =>
toast.error(
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON_CHARS) {
toast.error("Give a brief reason for dismissing");
return;
}
rejectMutation.mutate({
taskId: rejecting.taskId,
findingId: rejecting.finding.id,
reason: reason.trim(),
});
};
const handleApprove = (taskId: string, findingId: string) => {
setApprovingFindingId(findingId);
approveMutation.mutate({ taskId, findingId });
};
return (
<>
<Card>
<CardHeader>
<CardTitle>Market Briefs</CardTitle>
@@ -156,17 +355,58 @@ export function MarketBriefsTab() {
) : briefs.length === 0 ? (
<p className="text-sm text-muted-foreground">
No market briefs filed yet. The Head of Marketing files one
weekly, once Periscope is enabled they appear here as reports,
not a queue to act on.
weekly, once Periscope is enabled each cited finding can be
approved or dismissed individually once one lands.
</p>
) : (
<div className="space-y-4">
{briefs.map((b) => (
<BriefCard key={b.task_id} brief={b} />
<BriefCard
key={b.task_id}
brief={b}
onApprove={handleApprove}
onReject={setRejecting}
approvingFindingId={approvingFindingId}
/>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dismiss market-brief finding</DialogTitle>
<DialogDescription>
This records your reason and feeds the next cycle&apos;s prompt
it is not added to the backlog.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="periscope-reject-reason">Reason</Label>
<Textarea
id="periscope-reject-reason"
placeholder="e.g. not actionable right now; already covered by an open task..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,12 +1,34 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { sentinelApi } from "@/lib/api";
import type { QualityReport } from "@/lib/api/sentinel";
import type { QualityReport, QualityReportItem } from "@/lib/api/sentinel";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { CheckCircle2, XCircle } from "lucide-react";
import { toast } from "sonner";
const _MIN_REASON_CHARS = 4;
interface RejectTarget {
taskId: string;
item: QualityReportItem;
}
// ---------------------------------------------------------------------------
// Read-only skeleton placeholder shaped like a report card
@@ -27,12 +49,112 @@ function ReportCardSkeleton() {
);
}
function itemStatusBadge(item: QualityReportItem) {
if (item.status === "approved") {
return (
<HelpTip label="Materialized as a Main-PM-owned task">
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
Approved
</Badge>
</HelpTip>
);
}
if (item.status === "rejected") {
return (
<HelpTip label="Dismissed — not added to the backlog">
<Badge variant="outline">Dismissed</Badge>
</HelpTip>
);
}
return null;
}
// ---------------------------------------------------------------------------
// One report, read-only — headline/items/overall assessment. No approve/
// reject UI: a quality report is a report, not a queue item.
// One drift item — area/observation/evidence/suggested action, plus a
// per-item approve/dismiss action.
// ---------------------------------------------------------------------------
function ReportCard({ report }: { report: QualityReport }) {
function DriftItemRow({
taskId,
item,
onApprove,
onReject,
approving,
}: {
taskId: string;
item: QualityReportItem;
onApprove: (taskId: string, itemId: string) => void;
onReject: (target: RejectTarget) => void;
approving: boolean;
}) {
const isProposed = item.status === "proposed";
return (
<li className="space-y-1 border-b pb-3 last:border-b-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="uppercase text-xs">
{item.area}
</Badge>
{itemStatusBadge(item)}
</div>
<p className="text-sm whitespace-pre-wrap">{item.observation}</p>
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
Evidence: {item.evidence}
</p>
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
Suggested action: {item.suggested_action}
</p>
{item.status === "rejected" && item.reject_reason && (
<p className="text-xs text-destructive">
Dismissed: {item.reject_reason}
</p>
)}
{isProposed && (
<div className="flex justify-end gap-2 pt-1">
<HelpTip label="Records your reason — not added to the backlog">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onReject({ taskId, item })}
>
<XCircle className="mr-1 h-3.5 w-3.5" />
Dismiss
</Button>
</HelpTip>
<HelpTip label="Materializes this item as a Main-PM-owned task — needs normal PM activation to start">
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving}
onClick={() => onApprove(taskId, item.id)}
>
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Approve
</Button>
</HelpTip>
</div>
)}
</li>
);
}
// ---------------------------------------------------------------------------
// One report — headline/items/overall assessment, each item with its own
// approve/dismiss action.
// ---------------------------------------------------------------------------
function ReportCard({
report,
onApprove,
onReject,
approvingItemId,
}: {
report: QualityReport;
onApprove: (taskId: string, itemId: string) => void;
onReject: (target: RejectTarget) => void;
approvingItemId: string | null;
}) {
return (
<Card>
<CardHeader>
@@ -51,18 +173,14 @@ function ReportCard({ report }: { report: QualityReport }) {
</p>
<ul className="space-y-3">
{report.items.map((item) => (
<li key={item.id} className="text-sm space-y-1">
<Badge variant="secondary" className="uppercase text-xs">
{item.area}
</Badge>
<p className="whitespace-pre-wrap">{item.observation}</p>
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
Evidence: {item.evidence}
</p>
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
Suggested action: {item.suggested_action}
</p>
</li>
<DriftItemRow
key={item.id}
taskId={report.task_id}
item={item}
onApprove={onApprove}
onReject={onReject}
approving={approvingItemId === item.id}
/>
))}
</ul>
</div>
@@ -87,6 +205,11 @@ function ReportCard({ report }: { report: QualityReport }) {
// ---------------------------------------------------------------------------
export function QualityReportsTab() {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<RejectTarget | null>(null);
const [reason, setReason] = useState("");
const [approvingItemId, setApprovingItemId] = useState<string | null>(null);
const {
data: reports = [],
isLoading,
@@ -98,7 +221,76 @@ export function QualityReportsTab() {
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["sentinel", "reports"] });
const approveMutation = useMutation({
mutationFn: ({ taskId, itemId }: { taskId: string; itemId: string }) =>
sentinelApi.approveItem(taskId, itemId),
onSuccess: (result) => {
invalidate();
if (
result.status === "approved" ||
result.status === "already_approved"
) {
toast.success("Item approved — materialized as a task");
} else {
toast.warning(result.detail);
}
},
onError: (e) =>
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingItemId(null),
});
const rejectMutation = useMutation({
mutationFn: ({
taskId,
itemId,
reason,
}: {
taskId: string;
itemId: string;
reason: string;
}) => sentinelApi.rejectItem(taskId, itemId, reason),
onSuccess: () => {
invalidate();
toast.success("Item dismissed");
closeReject();
},
onError: (e) =>
toast.error(
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON_CHARS) {
toast.error("Give a brief reason for dismissing");
return;
}
rejectMutation.mutate({
taskId: rejecting.taskId,
itemId: rejecting.item.id,
reason: reason.trim(),
});
};
const handleApprove = (taskId: string, itemId: string) => {
setApprovingItemId(itemId);
approveMutation.mutate({ taskId, itemId });
};
return (
<>
<Card>
<CardHeader>
<CardTitle>Quality Reports</CardTitle>
@@ -118,17 +310,58 @@ export function QualityReportsTab() {
) : reports.length === 0 ? (
<p className="text-sm text-muted-foreground">
No quality reports filed yet. The Auditor files one weekly, once
Sentinel is enabled they appear here as reports, not a queue to
act on.
Sentinel is enabled each drift item can be approved or dismissed
individually once one lands.
</p>
) : (
<div className="space-y-4">
{reports.map((r) => (
<ReportCard key={r.task_id} report={r} />
<ReportCard
key={r.task_id}
report={r}
onApprove={handleApprove}
onReject={setRejecting}
approvingItemId={approvingItemId}
/>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dismiss quality-report item</DialogTitle>
<DialogDescription>
This records your reason and feeds the next cycle&apos;s prompt
it is not added to the backlog.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="sentinel-reject-reason">Reason</Label>
<Textarea
id="sentinel-reject-reason"
placeholder="e.g. already tracked elsewhere; not worth a task this cycle..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+36 -2
View File
@@ -4,8 +4,13 @@ import api from "./client";
// Coroner (Board Program) engine — the Auditor's event-triggered postmortem:
// a task bounced >=3x, was cancelled after work started, or was budget-
// blocked. One propose_postmortem call completes the autopsy atomically —
// there is no per-item approve/reject like Pest Control/Roadmap, so this is
// a plain read-only list. Mirrors lib/api/pest-control.ts.
// the EXPLORATION TASK has no per-item decision to wait on — but the
// postmortem's single process change still carries its own proposed/
// approved/rejected status the CEO decides on afterward (unless it already
// drafted a playbook: process_change_status "not_applicable", nothing left
// to decide). Unlike Periscope/Sentinel there is no item id — a postmortem
// is one process change, not a list — so the action routes key on the task
// id alone.
// ---------------------------------------------------------------------------
export interface Postmortem {
@@ -21,6 +26,16 @@ export interface Postmortem {
process_change_kind: string | null;
process_change_description: string | null;
playbook_id: string | null;
process_change_status:
"proposed" | "approved" | "rejected" | "not_applicable";
process_change_reject_reason: string | null;
process_change_materialized_task_id: string | null;
}
export interface ProcessChangeActionResult {
status: string;
materialized_task_id?: string | null;
detail: string;
}
export const coronerApi = {
@@ -28,4 +43,23 @@ export const coronerApi = {
const { data } = await api.get<Postmortem[]>("/coroner/postmortems");
return data;
},
approveProcessChange: async (
taskId: string,
): Promise<ProcessChangeActionResult> => {
const { data } = await api.post<ProcessChangeActionResult>(
`/coroner/postmortems/${taskId}/process-change/approve`,
{},
);
return data;
},
rejectProcessChange: async (
taskId: string,
reason: string,
): Promise<ProcessChangeActionResult> => {
const { data } = await api.post<ProcessChangeActionResult>(
`/coroner/postmortems/${taskId}/process-change/reject`,
{ reason },
);
return data;
},
};
+36 -3
View File
@@ -3,9 +3,11 @@ import api from "./client";
// ---------------------------------------------------------------------------
// Periscope (Board Program) engine — the Head of Marketing files a weekly
// market-research brief (competitors, adjacent-tool releases, positioning
// shifts). Unlike Roadmap/Pest Control this is a REPORT, not a queue item:
// read-only here, no approve/reject. Mirrors lib/api/pest-control.ts's shape
// minus the mutating routes.
// shifts). The brief itself is read-only — a report, not a queue item, the
// exploration task completes atomically at propose time — but each cited
// finding still carries its own proposed/approved/rejected status the CEO
// decides on afterward. Mirrors lib/api/roadmap.ts's per-item approve/reject
// shape.
// ---------------------------------------------------------------------------
export interface MarketBriefFinding {
@@ -13,6 +15,9 @@ export interface MarketBriefFinding {
claim: string;
source_url: string;
relevance: string;
status: "proposed" | "approved" | "rejected";
reject_reason?: string | null;
materialized_task_id?: string | null;
}
export interface MarketBrief {
@@ -26,9 +31,37 @@ export interface MarketBrief {
positioning_note: string;
}
export interface MarketBriefFindingActionResult {
status: string;
finding_id: string;
materialized_task_id?: string | null;
detail: string;
}
export const periscopeApi = {
listBriefs: async (): Promise<MarketBrief[]> => {
const { data } = await api.get<MarketBrief[]>("/periscope/briefs");
return data;
},
approveFinding: async (
taskId: string,
findingId: string,
): Promise<MarketBriefFindingActionResult> => {
const { data } = await api.post<MarketBriefFindingActionResult>(
`/periscope/briefs/${taskId}/findings/${findingId}/approve`,
{},
);
return data;
},
rejectFinding: async (
taskId: string,
findingId: string,
reason: string,
): Promise<MarketBriefFindingActionResult> => {
const { data } = await api.post<MarketBriefFindingActionResult>(
`/periscope/briefs/${taskId}/findings/${findingId}/reject`,
{ reason },
);
return data;
},
};
+36 -3
View File
@@ -3,9 +3,11 @@ import api from "./client";
// ---------------------------------------------------------------------------
// Sentinel (Board Program) engine — the Auditor files a weekly "state of
// quality" report (waiver-accumulation trends, conventions-violation
// hotspots, budget anomalies). Like Periscope this is a REPORT, not a queue
// item: read-only here, no approve/reject. Mirrors lib/api/periscope.ts's
// shape exactly.
// hotspots, budget anomalies). The report itself is read-only — a report,
// not a queue item, the exploration task completes atomically at propose
// time — but each drift item still carries its own proposed/approved/
// rejected status the CEO decides on afterward. Mirrors lib/api/
// periscope.ts's per-item approve/reject shape.
// ---------------------------------------------------------------------------
export interface QualityReportItem {
@@ -14,6 +16,9 @@ export interface QualityReportItem {
observation: string;
evidence: string;
suggested_action: string;
status: "proposed" | "approved" | "rejected";
reject_reason?: string | null;
materialized_task_id?: string | null;
}
export interface QualityReport {
@@ -25,9 +30,37 @@ export interface QualityReport {
overall_assessment: string;
}
export interface QualityReportItemActionResult {
status: string;
item_id: string;
materialized_task_id?: string | null;
detail: string;
}
export const sentinelApi = {
listReports: async (): Promise<QualityReport[]> => {
const { data } = await api.get<QualityReport[]>("/sentinel/reports");
return data;
},
approveItem: async (
taskId: string,
itemId: string,
): Promise<QualityReportItemActionResult> => {
const { data } = await api.post<QualityReportItemActionResult>(
`/sentinel/reports/${taskId}/items/${itemId}/approve`,
{},
);
return data;
},
rejectItem: async (
taskId: string,
itemId: string,
reason: string,
): Promise<QualityReportItemActionResult> => {
const { data } = await api.post<QualityReportItemActionResult>(
`/sentinel/reports/${taskId}/items/${itemId}/reject`,
{ reason },
);
return data;
},
};
+82 -8
View File
@@ -1,20 +1,31 @@
"""Coroner (Board Program) engine API — read-only Postmortems list.
"""Coroner (Board Program) engine API — the CEO reads filed postmortems and
approves/dismisses each one's process change.
Unlike Pest Control/Roadmap there is nothing here for the CEO to approve or
reject: a postmortem completes atomically the moment the Auditor calls
``propose_postmortem`` (spec §4). This route just lists what Coroner has
already found. CEO-only, mirroring every other Board Program surface.
A postmortem completes atomically at ``propose_postmortem`` time — the
EXPLORATION TASK has no per-item decision to wait on — but its single
process change still carries its own proposed/approved/rejected status the
CEO decides on afterward (unless kind="playbook", already routed into the
playbook queue). Unlike Periscope/Sentinel there is no item id: a postmortem
is one process change, not a list, so the action routes key on the task id
alone. CEO-only, mirroring every other Board Program surface.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.coroner import PostmortemResponse
from roboco.api.schemas.coroner import (
PostmortemResponse,
ProcessChangeActionResponse,
ProcessChangeRejectRequest,
)
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco
from roboco.services.coroner_service import get_coroner_service
from roboco.services.task import get_task_service
if TYPE_CHECKING:
@@ -24,7 +35,7 @@ router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view the Coroner postmortems list")
require_ceo_role(agent.role, action="view or act on the Coroner postmortems list")
def _to_response(task: TaskTable) -> PostmortemResponse:
@@ -44,6 +55,9 @@ def _to_response(task: TaskTable) -> PostmortemResponse:
process_change_kind=process_change.get("kind"),
process_change_description=process_change.get("description"),
playbook_id=postmortem.get("playbook_id"),
process_change_status=process_change.get("status", "proposed"),
process_change_reject_reason=process_change.get("reject_reason"),
process_change_materialized_task_id=process_change.get("materialized_task_id"),
)
@@ -55,3 +69,63 @@ async def list_postmortems(
_require_ceo(agent)
tasks = await get_task_service(db).list_completed_coroner_postmortems()
return [_to_response(t) for t in tasks]
@router.post(
"/postmortems/{task_id}/process-change/approve",
response_model=ProcessChangeActionResponse,
)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.block_clouds()
async def approve_process_change(
task_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> ProcessChangeActionResponse:
"""Materialize the postmortem's process change as a Main-PM-owned root
task (idempotent)."""
_require_ceo(agent)
result = await get_coroner_service(db).approve_process_change(
task_id, created_by=agent.agent_id
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No such Coroner postmortem",
)
await db.commit()
return ProcessChangeActionResponse(
status=result.status,
materialized_task_id=result.materialized_task_id,
detail=result.detail,
)
@router.post(
"/postmortems/{task_id}/process-change/reject",
response_model=ProcessChangeActionResponse,
)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.block_clouds()
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
async def reject_process_change(
task_id: UUID,
data: ProcessChangeRejectRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> ProcessChangeActionResponse:
"""Dismiss the postmortem's process change with a reason (idempotent)."""
_require_ceo(agent)
result = await get_coroner_service(db).reject_process_change(task_id, data.reason)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No such Coroner postmortem",
)
await db.commit()
return ProcessChangeActionResponse(
status=result.status,
materialized_task_id=result.materialized_task_id,
detail=result.detail,
)
+83 -7
View File
@@ -1,16 +1,25 @@
"""Periscope (Board Program) engine API — the CEO reads filed market briefs.
CEO-only throughout. Read-only: a brief is a report, not a queue item — there
is no approve/reject route here, unlike roadmap/pest_control. Mirrors
``roboco.api.routes.pest_control``'s CEO-gating shape.
"""Periscope (Board Program) engine API — the CEO reads filed market briefs
and approves/dismisses individual findings. CEO-only throughout. The brief
itself is a report (read-only — the exploration task completes atomically at
propose time), but each finding carries its own per-item approve/reject,
mirroring ``roboco.api.routes.roadmap``'s shape.
"""
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.periscope import MarketBriefFindingResponse, MarketBriefResponse
from roboco.api.schemas.periscope import (
MarketBriefFindingActionResponse,
MarketBriefFindingRejectRequest,
MarketBriefFindingResponse,
MarketBriefResponse,
)
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco
from roboco.services.periscope_service import get_periscope_service
from roboco.services.task import get_task_service
if TYPE_CHECKING:
@@ -20,7 +29,9 @@ router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view the Periscope market-briefs list")
require_ceo_role(
agent.role, action="view or act on the Periscope market-briefs list"
)
def _to_response(task: "TaskTable") -> MarketBriefResponse | None:
@@ -50,3 +61,68 @@ async def list_market_briefs(
_require_ceo(agent)
tasks = await get_task_service(db).list_periscope_briefs()
return [r for t in tasks if (r := _to_response(t)) is not None]
@router.post(
"/briefs/{task_id}/findings/{finding_id}/approve",
response_model=MarketBriefFindingActionResponse,
)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.block_clouds()
async def approve_market_brief_finding(
task_id: UUID,
finding_id: str,
db: DbSession,
agent: CurrentAgentContext,
) -> MarketBriefFindingActionResponse:
"""Materialize one finding as a Main-PM-owned root task (idempotent)."""
_require_ceo(agent)
result = await get_periscope_service(db).approve_finding(
task_id, finding_id, created_by=agent.agent_id
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No such open Periscope finding",
)
await db.commit()
return MarketBriefFindingActionResponse(
status=result.status,
finding_id=result.finding_id,
materialized_task_id=result.materialized_task_id,
detail=result.detail,
)
@router.post(
"/briefs/{task_id}/findings/{finding_id}/reject",
response_model=MarketBriefFindingActionResponse,
)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.block_clouds()
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
async def reject_market_brief_finding(
task_id: UUID,
finding_id: str,
data: MarketBriefFindingRejectRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> MarketBriefFindingActionResponse:
"""Dismiss one finding with a reason (idempotent)."""
_require_ceo(agent)
result = await get_periscope_service(db).reject_finding(
task_id, finding_id, data.reason
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No such open Periscope finding",
)
await db.commit()
return MarketBriefFindingActionResponse(
status=result.status,
finding_id=result.finding_id,
materialized_task_id=result.materialized_task_id,
detail=result.detail,
)
+81 -7
View File
@@ -1,16 +1,25 @@
"""Sentinel (Board Program) engine API — the CEO reads filed quality reports.
CEO-only throughout. Read-only: a report is a report, not a queue item — there
is no approve/reject route here, unlike roadmap/pest_control. Mirrors
``roboco.api.routes.periscope``'s CEO-gating shape.
"""Sentinel (Board Program) engine API — the CEO reads filed quality reports
and approves/dismisses individual drift items. CEO-only throughout. The
report itself is read-only (the exploration task completes atomically at
propose time), but each item carries its own per-item approve/reject,
mirroring ``roboco.api.routes.periscope``'s shape.
"""
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.sentinel import QualityReportItemResponse, QualityReportResponse
from roboco.api.schemas.sentinel import (
QualityReportItemActionResponse,
QualityReportItemRejectRequest,
QualityReportItemResponse,
QualityReportResponse,
)
from roboco.foundation.policy.content import markers
from roboco.security import guard_deco
from roboco.services.sentinel_service import get_sentinel_service
from roboco.services.task import get_task_service
if TYPE_CHECKING:
@@ -20,7 +29,9 @@ router = APIRouter()
def _require_ceo(agent: CurrentAgentContext) -> None:
require_ceo_role(agent.role, action="view the Sentinel quality-reports list")
require_ceo_role(
agent.role, action="view or act on the Sentinel quality-reports list"
)
def _to_response(task: "TaskTable") -> QualityReportResponse | None:
@@ -48,3 +59,66 @@ async def list_quality_reports(
_require_ceo(agent)
tasks = await get_task_service(db).list_sentinel_reports()
return [r for t in tasks if (r := _to_response(t)) is not None]
@router.post(
"/reports/{task_id}/items/{item_id}/approve",
response_model=QualityReportItemActionResponse,
)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.block_clouds()
async def approve_quality_report_item(
task_id: UUID,
item_id: str,
db: DbSession,
agent: CurrentAgentContext,
) -> QualityReportItemActionResponse:
"""Materialize one drift item as a Main-PM-owned root task (idempotent)."""
_require_ceo(agent)
result = await get_sentinel_service(db).approve_item(
task_id, item_id, created_by=agent.agent_id
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No such open Sentinel item",
)
await db.commit()
return QualityReportItemActionResponse(
status=result.status,
item_id=result.item_id,
materialized_task_id=result.materialized_task_id,
detail=result.detail,
)
@router.post(
"/reports/{task_id}/items/{item_id}/reject",
response_model=QualityReportItemActionResponse,
)
@guard_deco.rate_limit(requests=30, window=60)
@guard_deco.block_clouds()
@guard_deco.content_type_filter(["application/json"])
@guard_deco.honeypot_detection(["email", "phone", "website"])
async def reject_quality_report_item(
task_id: UUID,
item_id: str,
data: QualityReportItemRejectRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> QualityReportItemActionResponse:
"""Dismiss one drift item with a reason (idempotent)."""
_require_ceo(agent)
result = await get_sentinel_service(db).reject_item(task_id, item_id, data.reason)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No such open Sentinel item",
)
await db.commit()
return QualityReportItemActionResponse(
status=result.status,
item_id=result.item_id,
materialized_task_id=result.materialized_task_id,
detail=result.detail,
)
+30 -5
View File
@@ -1,13 +1,18 @@
"""Schemas for the Coroner (Board Program) engine's read-only CEO surface.
"""Schemas for the Coroner (Board Program) engine's CEO surface.
Unlike Pest Control/Roadmap there is no approve/reject action here — a
postmortem completes atomically at ``propose_postmortem`` time (spec §4:
"report asymmetry — no per-item CEO decision"), so this is a plain list.
A postmortem completes atomically at ``propose_postmortem`` time — the
EXPLORATION TASK has no per-item CEO decision to wait on — but its single
``process_change`` still carries its own proposed/approved/rejected status
for the CEO to decide on afterward (unless its kind is "playbook", already
routed into the playbook curation queue: status "not_applicable"). Unlike
Periscope/Sentinel there is no item id — a postmortem is one process change,
not a list (``roboco.services.coroner_engine``'s own docstring), so the
action routes key on the task id alone.
"""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field
class PostmortemResponse(BaseModel):
@@ -26,3 +31,23 @@ class PostmortemResponse(BaseModel):
process_change_kind: str | None
process_change_description: str | None
playbook_id: str | None
# Defaults cover a postmortem authored before this feature shipped,
# whose stored process_change carries none of these three keys.
process_change_status: str = "proposed"
process_change_reject_reason: str | None = None
process_change_materialized_task_id: str | None = None
class ProcessChangeRejectRequest(BaseModel):
"""The CEO's reason for dismissing a postmortem's process change."""
reason: str = Field(..., min_length=4)
class ProcessChangeActionResponse(BaseModel):
"""The outcome of an approve/reject call on a postmortem's process
change."""
status: str
materialized_task_id: str | None = None
detail: str
+29 -4
View File
@@ -1,10 +1,15 @@
"""Schemas for the Periscope (Board Program) engine's CEO-facing read surface.
Mirrors ``roboco.api.schemas.pest_control`` — a report has no per-item
approve/reject, so this is list-only."""
"""Schemas for the Periscope (Board Program) engine's CEO-facing surface.
The report (headline/findings/threats/opportunities/positioning_note) is
read-only — a brief has no per-item approve/reject at the TASK level (the
exploration task completes atomically at propose time). Each FINDING still
carries its own proposed/approved/rejected status the CEO decides on
afterward — ``MarketBriefFindingResponse`` and the action schemas below back
that per-finding queue, mirrored on ``roboco.api.schemas.roadmap``."""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field
class MarketBriefFindingResponse(BaseModel):
@@ -14,6 +19,11 @@ class MarketBriefFindingResponse(BaseModel):
claim: str
source_url: str
relevance: str
# Defaults cover a finding authored before this feature shipped, whose
# stored marker carries none of these three keys.
status: str = "proposed"
reject_reason: str | None = None
materialized_task_id: str | None = None
class MarketBriefResponse(BaseModel):
@@ -27,3 +37,18 @@ class MarketBriefResponse(BaseModel):
threats: list[str]
opportunities: list[str]
positioning_note: str
class MarketBriefFindingRejectRequest(BaseModel):
"""The CEO's reason for dismissing one market-brief finding."""
reason: str = Field(..., min_length=4)
class MarketBriefFindingActionResponse(BaseModel):
"""The outcome of an approve/reject call on one market-brief finding."""
status: str
finding_id: str
materialized_task_id: str | None = None
detail: str
+27 -4
View File
@@ -1,10 +1,13 @@
"""Schemas for the Sentinel (Board Program) engine's CEO-facing read surface.
Mirrors ``roboco.api.schemas.periscope`` — a report has no per-item
approve/reject, so this is list-only."""
"""Schemas for the Sentinel (Board Program) engine's CEO-facing surface.
Mirrors ``roboco.api.schemas.periscope`` exactly: the report itself is
read-only (the exploration task completes atomically at propose time), but
each drift ITEM still carries its own proposed/approved/rejected status the
CEO decides on afterward."""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field
class QualityReportItemResponse(BaseModel):
@@ -15,6 +18,11 @@ class QualityReportItemResponse(BaseModel):
observation: str
evidence: str
suggested_action: str
# Defaults cover an item authored before this feature shipped, whose
# stored marker carries none of these three keys.
status: str = "proposed"
reject_reason: str | None = None
materialized_task_id: str | None = None
class QualityReportResponse(BaseModel):
@@ -26,3 +34,18 @@ class QualityReportResponse(BaseModel):
headline: str
items: list[QualityReportItemResponse]
overall_assessment: str
class QualityReportItemRejectRequest(BaseModel):
"""The CEO's reason for dismissing one quality-report item."""
reason: str = Field(..., min_length=4)
class QualityReportItemActionResponse(BaseModel):
"""The outcome of an approve/reject call on one quality-report item."""
status: str
item_id: str
materialized_task_id: str | None = None
detail: str
+23 -11
View File
@@ -355,11 +355,15 @@ def set_messaging_fixes(task: HasMarkers, payload: dict[str, Any]) -> None:
# --- Periscope market brief --------------------------------------------------
# The Head of Marketing's weekly market-research report, authored via
# ``propose_market_brief`` onto the exploration task the Periscope engine
# opened: {headline, findings (list of {id, claim, source_url, relevance}),
# threats, opportunities, positioning_note, injection_hits}. Unlike
# ROADMAP_CYCLE/PEST_HUNT there is no per-item CEO decision — the verb
# completes the exploration task in the same call (mirrors X_FEATURE_REF's
# complete-at-propose asymmetry), so this marker is set exactly once.
# opened: {headline, findings (list of {id, claim, source_url, relevance,
# status, reject_reason, materialized_task_id}), threats, opportunities,
# positioning_note, injection_hits}. Unlike ROADMAP_CYCLE/PEST_HUNT the
# EXPLORATION TASK itself has no per-item decision to wait on — the verb
# completes it in the same call (mirrors X_FEATURE_REF's complete-at-propose
# asymmetry), so this marker is set exactly once. Each FINDING still carries
# its own proposed/approved/rejected status the CEO decides afterward
# (PeriscopeService.approve_finding/reject_finding), independent of the
# task's own terminal status.
def get_market_brief(task: HasMarkers) -> dict[str, Any] | None:
@@ -375,9 +379,15 @@ def set_market_brief(task: HasMarkers, payload: dict[str, Any]) -> None:
# The incident ref the CoronerEngine stamps on the postmortem-exploration task
# it opens ({incident_task_id, kind, revision_count, title}), and the
# Auditor-authored postmortem ({incident_summary, root_cause, failed_stage,
# process_change, playbook_id?}) it writes via ``propose_postmortem``. Unlike
# ROADMAP_CYCLE/PEST_HUNT there is no per-item status — a single call
# completes the task, so this is set-once, read-only after that.
# process_change: {kind, description, status, reject_reason,
# materialized_task_id}, playbook_id?}) it writes via ``propose_postmortem``.
# A single call completes the EXPLORATION TASK — unlike ROADMAP_CYCLE/
# PEST_HUNT there is no multi-item queue to keep it open for — but the one
# ``process_change`` still carries its own proposed/approved/rejected status
# for the CEO's after-the-fact decision (CoronerService.
# approve_process_change/reject_process_change), except when its kind is
# "playbook" (status "not_applicable" — already routed straight into the
# playbook curation queue, nothing left to decide here).
def get_coroner_incident(task: HasMarkers) -> dict[str, Any] | None:
@@ -402,9 +412,11 @@ def set_coroner_postmortem(task: HasMarkers, payload: dict[str, Any]) -> None:
# The Auditor's weekly org-wide drift report, authored via
# ``propose_quality_report`` onto the exploration task the sentinel engine
# opened: {headline, items (list of {id, area, observation, evidence,
# suggested_action}), overall_assessment}. Mirrors MARKET_BRIEF exactly — no
# per-item CEO decision, so this marker is set exactly once (complete-at-
# propose).
# suggested_action, status, reject_reason, materialized_task_id}),
# overall_assessment}. Mirrors MARKET_BRIEF exactly — the exploration task
# is set exactly once (complete-at-propose), but each ITEM still carries its
# own proposed/approved/rejected status the CEO decides afterward
# (SentinelService.approve_item/reject_item).
def get_quality_report(task: HasMarkers) -> dict[str, Any] | None:
+326
View File
@@ -0,0 +1,326 @@
"""CoronerService — the CEO's approve/dismiss glue over a completed
Coroner postmortem's ONE process change.
The Coroner engine opens a HELD, EVENT-triggered postmortem-exploration task
(``board_coroner`` source) when an incident task bounces 3+ times, is
cancelled after work started, or is budget-blocked. The Auditor autopsies it
and files the postmortem via ``propose_postmortem`` (persisted as a marker
payload — see
``roboco.foundation.policy.content.markers.get_coroner_postmortem``), which
completes the exploration task in that same call — a report, not a per-item
queue.
Unlike Periscope/Sentinel, a postmortem is ONE process change, not a list of
items (``roboco.services.coroner_engine``'s own docstring: "a postmortem is
one process change, not a list of items") — so there is no item id to key
on, just the task id. ``approve_process_change`` materializes it as a
PENDING, Main-PM-owned root task (``source=coroner``, ``assigned_to=
main-pm`` — see ``RoadmapService._materialize``'s docstring for why never a
parentless BACKLOG task); ``reject_process_change`` records the reason
("dismiss" — no task). Both are idempotent. A ``kind="playbook"`` change
already routed straight into the playbook curation queue at propose time
(``ContentActions._draft_coroner_playbook``) — it carries marker status
``not_applicable`` and both actions here refuse it outright (result status
``invalid_state``): nothing is left to decide.
A process change carries no ``project_slug`` the way a roadmap item does.
The target project resolves to the INCIDENT task's own project (re-fetched
via the ``coroner_incident`` marker's ``incident_task_id`` — the same
resolution ``CoronerEngine._originate`` already uses to anchor the
postmortem-exploration task itself: "Project is the incident's own — an
autopsy is about that incident, wherever it lived"), falling back to
RoboCo's own project (``settings.self_heal_project_slug``) when the incident
is gone or carries no project — mirroring ``CoronerEngine._originate``'s own
``incident.project_id or await self._roboco_project_id()`` fallback exactly.
"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from roboco.config import settings
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus, Team
from roboco.services.base import BaseService
from roboco.services.board_programs import learn_ref
from roboco.services.task import CORONER_ITEM_SOURCE, CORONER_SOURCE
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
_PLAYBOOK_KIND = "playbook"
@dataclass(frozen=True)
class ProcessChangeResult:
"""Outcome of an approve/reject call on a postmortem's process change.
`status` is one of: approved, already_approved, rejected,
already_rejected, invalid_state.
"""
status: str
materialized_task_id: str | None
detail: str
class CoronerService(BaseService):
"""Approve / reject the process change on a completed Coroner postmortem."""
service_name = "coroner_service"
async def approve_process_change(
self, task_id: UUID, *, created_by: UUID
) -> ProcessChangeResult | None:
"""Materialize the postmortem's process change as a Main-PM-owned
root task.
Returns None when ``task_id`` carries no Coroner postmortem.
Idempotent: an already-approved change returns its stored
materialized task id without creating a duplicate. An
already-rejected change cannot be approved. A ``kind="playbook"``
change refuses outright — it already drafted into the playbook
queue at propose time.
"""
task, payload, process_change = await self._find(task_id)
if task is None or payload is None or process_change is None:
return None
if process_change["kind"] == _PLAYBOOK_KIND:
return self._playbook_result()
if process_change["status"] == "approved":
return ProcessChangeResult(
status="already_approved",
materialized_task_id=process_change.get("materialized_task_id"),
detail="this process change was already approved",
)
if process_change["status"] != "proposed":
return ProcessChangeResult(
status="invalid_state",
materialized_task_id=None,
detail=(
f"process change is {process_change['status']!r}, not "
"proposed — cannot approve"
),
)
try:
new_task = await self._materialize(task, payload, created_by=created_by)
except ValueError as exc:
return ProcessChangeResult(
status="invalid_state", materialized_task_id=None, detail=str(exc)
)
process_change["status"] = "approved"
process_change["materialized_task_id"] = str(new_task.id)
payload["process_change"] = process_change
markers.set_coroner_postmortem(task, payload)
await self._record_learn(task, process_change, "approved")
await self.session.flush()
return ProcessChangeResult(
status="approved",
materialized_task_id=str(new_task.id),
detail="materialized as a Main-PM-owned task",
)
async def reject_process_change(
self, task_id: UUID, reason: str
) -> ProcessChangeResult | None:
"""Dismiss the postmortem's process change, recording the CEO's
reason.
Idempotent: an already-rejected change returns its stored reason
without re-recording. An already-approved change cannot be rejected.
A ``kind="playbook"`` change refuses outright — see
``approve_process_change``.
"""
task, payload, process_change = await self._find(task_id)
if task is None or payload is None or process_change is None:
return None
if process_change["kind"] == _PLAYBOOK_KIND:
return self._playbook_result()
if process_change["status"] == "rejected":
return ProcessChangeResult(
status="already_rejected",
materialized_task_id=None,
detail="this process change was already dismissed",
)
if process_change["status"] != "proposed":
return ProcessChangeResult(
status="invalid_state",
materialized_task_id=process_change.get("materialized_task_id"),
detail=(
f"process change is {process_change['status']!r}, not "
"proposed — cannot dismiss"
),
)
process_change["status"] = "rejected"
process_change["reject_reason"] = reason
payload["process_change"] = process_change
markers.set_coroner_postmortem(task, payload)
await self._record_learn(task, process_change, "rejected", reason)
await self.session.flush()
return ProcessChangeResult(
status="rejected",
materialized_task_id=None,
detail="dismissed; feeds the next cycle's prompt",
)
@staticmethod
def _playbook_result() -> ProcessChangeResult:
return ProcessChangeResult(
status="invalid_state",
materialized_task_id=None,
detail=(
"this process change already drafted as a playbook — see the "
"playbook review queue, there is nothing else to decide here"
),
)
async def _find(
self, task_id: UUID
) -> tuple[TaskTable | None, dict[str, Any] | None, dict[str, Any] | None]:
"""Resolve (exploration task, postmortem payload, the process
change) or (None, None, None). Deep-copies the stored marker before
mutating it — see ``RoadmapService._find_item``'s identical
dirty-check rationale.
A postmortem authored before this feature shipped carries no
``status`` key on its process change at all — ``setdefault`` treats
it as ``proposed`` rather than crashing on a missing key.
"""
from roboco.services.task import get_task_service
task = await get_task_service(self.session).get(task_id)
if task is None or task.source != CORONER_SOURCE:
return None, None, None
stored = markers.get_coroner_postmortem(task)
if stored is None:
return None, None, None
payload = copy.deepcopy(stored)
process_change = payload.get("process_change")
if not isinstance(process_change, dict):
return None, None, None
process_change.setdefault("status", "proposed")
return task, payload, process_change
async def _materialize(
self,
task: TaskTable,
payload: dict[str, Any],
*,
created_by: UUID,
) -> TaskTable:
"""Turn the approved process change into a real Main-PM-owned root
task, anchored on the incident's own project (see module docstring
for why). ``payload`` is the full deep-copied postmortem dict
``_find`` already read — reused here rather than re-reading the
task's marker column mid-transaction, before this same call's
caller has written the approved status back.
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), matching
``TaskService.approve_and_start`` — the incident's own team (resolved
by ``_resolve_target``) is no longer the task's ``team`` column; it
survives as a Notes delegation hint instead."""
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.prompter import BatchPlacement, get_prompter_service
project_id, team = await self._resolve_target(task)
if project_id is None:
raise ValueError(
"neither the incident's own project nor the RoboCo project "
"(settings.self_heal_project_slug) is resolvable — cannot "
"anchor a materialized task"
)
process_change = payload["process_change"]
notes = [f"Root cause: {payload.get('root_cause', '')}".strip()]
incident_summary = payload.get("incident_summary")
if incident_summary:
notes.append(f"Incident: {incident_summary}")
notes.append(
f"Delegation hint: the incident lived in the {team.value} cell — "
f"delegate into the {team.value} cell."
)
draft = {
"title": f"Postmortem follow-up: {process_change['description']}"[:200],
"objective": process_change["description"],
"notes": notes,
"acceptance_criteria": [process_change["description"]],
"project_id": str(project_id),
"team": team.value,
"priority": 2,
"source": CORONER_ITEM_SOURCE,
}
return await get_prompter_service(self.session).create_task_from_draft(
draft,
created_by,
status=TaskStatus.PENDING,
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
placement=BatchPlacement(team_override=Team.MAIN_PM),
)
async def _resolve_target(self, task: TaskTable) -> tuple[UUID | None, Team]:
"""(project_id, team) for the materialized follow-up — the
incident's own, else RoboCo's project + Team.BACKEND. Mirrors
``CoronerEngine._originate``'s ``incident.project_id or await
self._roboco_project_id()`` exactly, extended to also resolve the
incident's own team, which ``_materialize`` now carries forward as a
Notes delegation hint rather than the materialized task's ``team``
column (forced to ``Team.MAIN_PM`` — see ``_materialize``'s
docstring)."""
incident_ref = markers.get_coroner_incident(task) or {}
incident_task_id = incident_ref.get("incident_task_id")
if incident_task_id:
from roboco.services.task import get_task_service
incident = await get_task_service(self.session).get(UUID(incident_task_id))
if incident is not None and incident.project_id is not None:
return cast("UUID", incident.project_id), incident.team or Team.BACKEND
project = await self._roboco_project()
if project is not None:
return cast("UUID", project.id), Team.BACKEND
return None, Team.BACKEND
async def _roboco_project(self) -> Any:
"""Mirrors ``CoronerEngine._roboco_project_id`` exactly — the same
fallback anchor the postmortem-exploration task itself resolves
against when the incident carries no project."""
from roboco.services.project import get_project_service
slug = (settings.self_heal_project_slug or "roboco-api").strip()
return await get_project_service(self.session).get_by_slug(slug)
async def _record_learn(
self,
task: TaskTable,
process_change: dict[str, Any],
verdict: str,
reason: str | None = None,
) -> None:
"""Best-effort LEARN: a record_decision failure must never break the
CEO's approve/reject — mirrors ``RoadmapService._record_learn``.
``learn_ref`` expects a ``title``/``target_task_title`` field; a
process change carries neither, so it's wrapped with its
``description`` under ``title`` rather than reinventing the
truncation/fallback logic.
"""
try:
from roboco.services.board_programs import get_board_program_engine
await get_board_program_engine(self.session).record_decision(
"coroner",
learn_ref({"title": process_change.get("description")}),
verdict,
reason,
exploration_task_id=cast("UUID", task.id),
)
except Exception:
self.log.warning("coroner: LEARN record_decision failed (best-effort)")
def get_coroner_service(session: AsyncSession) -> CoronerService:
"""Construct a CoronerService bound to ``session``."""
return CoronerService(session)
+26 -12
View File
@@ -6,11 +6,15 @@ the Product Owner authors the friction-fix drafts onto it via
``propose_friction_fixes`` (1-5 evidence-backed item drafts, persisted as a
marker payload — see ``roboco.foundation.policy.content.markers.
get_friction_fixes``). This service is what the CEO-gated routes call:
``approve_item`` materializes one item as a BACKLOG task (``source=dogfood``,
via ``PrompterService.create_task_from_draft`` — CEO approval IS the
confirmation); ``reject_item`` records the reason. Once every item on the
cycle is terminal (approved/rejected) the exploration task itself completes.
Both actions are idempotent per item. Mirrors ``SpackleService`` exactly.
``approve_item`` materializes one item as a PENDING, Main-PM-owned root task
(``source=dogfood``, ``assigned_to=main-pm``, via ``PrompterService.
create_task_from_draft`` — CEO approval IS the confirmation); ``reject_item``
records the reason. Once every item on the cycle is terminal
(approved/rejected) the exploration task itself completes. Both actions are
idempotent per item. Mirrors ``SpackleService`` exactly.
A materialized item is NEVER an unowned BACKLOG task — see
``RoadmapService._materialize``'s docstring for why.
"""
from __future__ import annotations
@@ -18,17 +22,16 @@ from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from roboco.foundation.policy.board_programs import PROGRAMS, project_participates
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus
from roboco.models.base import TaskStatus, Team
from roboco.services.base import BaseService
from roboco.services.board_programs import learn_ref
from roboco.services.task import DOGFOOD_ITEM_SOURCE, DOGFOOD_SOURCE
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
@@ -176,9 +179,14 @@ class DogfoodService(BaseService):
async def _materialize(
self, item: dict[str, Any], *, created_by: UUID
) -> TaskTable:
"""Turn one approved item draft into a real BACKLOG task."""
"""Turn one approved item draft into a Main-PM-owned root task.
Mirrors ``RoadmapService._materialize`` — PENDING + main-pm and
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), not a
parentless BACKLOG task and not left on the item's own cell team; the
item's own cell survives as a Notes delegation hint instead."""
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.project import get_project_service
from roboco.services.prompter import get_prompter_service
from roboco.services.prompter import BatchPlacement, get_prompter_service
project = await get_project_service(self.session).get_by_slug(
item["project_slug"]
@@ -197,7 +205,11 @@ class DogfoodService(BaseService):
draft = {
"title": item["title"],
"objective": item["description"],
"notes": [f"Evidence: {item['evidence']}"],
"notes": [
f"Evidence: {item['evidence']}",
f"Delegation hint: originated as a {item['team']} item — "
f"delegate into the {item['team']} cell.",
],
"acceptance_criteria": item["acceptance_criteria"],
"project_id": str(project.id),
"team": item["team"],
@@ -207,7 +219,9 @@ class DogfoodService(BaseService):
return await get_prompter_service(self.session).create_task_from_draft(
draft,
created_by,
status=TaskStatus.BACKLOG,
status=TaskStatus.PENDING,
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
placement=BatchPlacement(team_override=Team.MAIN_PM),
)
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
+29 -2
View File
@@ -682,25 +682,39 @@ def _normalize_friction_fix_item(idx: int, raw: dict[str, Any]) -> dict[str, Any
def _normalize_market_brief_finding(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
"""Coerce a validated raw market-brief finding into the stored marker
shape. Mirrors ``_normalize_pest_hunt_item`` ``id`` is server-assigned."""
shape. Mirrors ``_normalize_pest_hunt_item`` ``id`` is server-assigned.
``status``/``reject_reason``/``materialized_task_id`` mirror the roadmap/
pest-hunt item shape even though the exploration task itself completes
at propose time the finding still carries its OWN per-item CEO
decision (``PeriscopeService.approve_finding``/``reject_finding``),
orthogonal to the task's own terminal status.
"""
return {
"id": f"finding-{idx}",
"claim": str(raw["claim"]).strip(),
"source_url": str(raw["source_url"]).strip(),
"relevance": str(raw["relevance"]).strip(),
"status": "proposed",
"reject_reason": None,
"materialized_task_id": None,
}
def _normalize_quality_report_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
"""Coerce a validated raw quality-report item into the stored marker
shape. Mirrors ``_normalize_market_brief_finding`` ``id`` is
server-assigned."""
server-assigned, and the same per-item ``status`` triple applies (see
``SentinelService.approve_item``/``reject_item``)."""
return {
"id": f"item-{idx}",
"area": str(raw["area"]).strip(),
"observation": str(raw["observation"]).strip(),
"evidence": str(raw["evidence"]).strip(),
"suggested_action": str(raw["suggested_action"]).strip(),
"status": "proposed",
"reject_reason": None,
"materialized_task_id": None,
}
@@ -4892,6 +4906,19 @@ class ContentActions:
"process_change": {
"kind": process_change["kind"],
"description": str(process_change["description"]).strip(),
# A "playbook" kind already routed straight into the
# playbook curation queue above — nothing left for the
# CEO to decide on THIS process change, so it never
# enters the proposed/approved/rejected per-item flow
# (CoronerService.approve_process_change/
# reject_process_change refuse it outright).
"status": (
"not_applicable"
if process_change["kind"] == "playbook"
else "proposed"
),
"reject_reason": None,
"materialized_task_id": None,
},
"playbook_id": playbook_id,
},
+26 -13
View File
@@ -6,12 +6,15 @@ the Head of Marketing authors the messaging audit onto it via
``propose_messaging_fixes`` (1-5 evidence-backed item drafts, persisted as a
marker payload — see ``roboco.foundation.policy.content.markers.
get_messaging_fixes``). This service is what the CEO-gated routes call:
``approve_item`` materializes one item as a BACKLOG docs task
(``source=mirror``, ``task_type=documentation``, via ``PrompterService.
create_task_from_draft`` — CEO approval IS the confirmation); ``reject_item``
records the reason. Once every item on the cycle is terminal
(approved/rejected) the exploration task itself completes. Both actions are
idempotent per item. Mirrors ``SpackleService`` exactly.
``approve_item`` materializes one item as a PENDING, Main-PM-owned docs root
task (``source=mirror``, ``task_type=documentation``, ``assigned_to=main-pm``,
via ``PrompterService.create_task_from_draft`` — CEO approval IS the
confirmation); ``reject_item`` records the reason. Once every item on the
cycle is terminal (approved/rejected) the exploration task itself completes.
Both actions are idempotent per item. Mirrors ``SpackleService`` exactly.
A materialized item is NEVER an unowned BACKLOG task — see
``RoadmapService._materialize``'s docstring for why.
"""
from __future__ import annotations
@@ -19,17 +22,16 @@ from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from roboco.foundation.policy.board_programs import PROGRAMS, project_participates
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus
from roboco.models.base import TaskStatus, Team
from roboco.services.base import BaseService
from roboco.services.board_programs import learn_ref
from roboco.services.task import MIRROR_ITEM_SOURCE, MIRROR_SOURCE
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
@@ -177,9 +179,14 @@ class MirrorService(BaseService):
async def _materialize(
self, item: dict[str, Any], *, created_by: UUID
) -> TaskTable:
"""Turn one approved item draft into a real BACKLOG docs task."""
"""Turn one approved item draft into a Main-PM-owned docs root task.
Mirrors ``RoadmapService._materialize`` — PENDING + main-pm and
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), not a
parentless BACKLOG task and not left on the item's own cell team; the
item's own cell survives as a Notes delegation hint instead."""
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.project import get_project_service
from roboco.services.prompter import get_prompter_service
from roboco.services.prompter import BatchPlacement, get_prompter_service
project = await get_project_service(self.session).get_by_slug(
item["project_slug"]
@@ -197,7 +204,11 @@ class MirrorService(BaseService):
draft = {
"title": item["title"],
"objective": item["description"],
"notes": [f"Evidence: {item['evidence']}"],
"notes": [
f"Evidence: {item['evidence']}",
f"Delegation hint: originated as a {item['team']} item — "
f"delegate into the {item['team']} cell.",
],
"acceptance_criteria": item["acceptance_criteria"],
"project_id": str(project.id),
"team": item["team"],
@@ -208,7 +219,9 @@ class MirrorService(BaseService):
return await get_prompter_service(self.session).create_task_from_draft(
draft,
created_by,
status=TaskStatus.BACKLOG,
status=TaskStatus.PENDING,
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
placement=BatchPlacement(team_override=Team.MAIN_PM),
)
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
+268
View File
@@ -0,0 +1,268 @@
"""PeriscopeService — the CEO's per-finding approve/dismiss glue over a
completed Periscope market brief.
The Periscope engine opens a HELD exploration task (``board_periscope``
source); the Head of Marketing files ONE brief onto it via
``propose_market_brief`` (a headline + 1-7 cited findings, persisted as a
marker payload — see
``roboco.foundation.policy.content.markers.get_market_brief``) and the
exploration task completes in that same call — a report, not a per-item
queue (mirrors ``RoadmapService``'s docstring on this point exactly).
Unlike the exploration task, each FINDING still carries its own
proposed/approved/rejected status the CEO decides on afterward — that is
what this service is for. ``approve_finding`` materializes one finding as a
PENDING, Main-PM-owned root task (``source=periscope``, ``assigned_to=
main-pm`` — see ``RoadmapService._materialize``'s docstring for why never a
parentless BACKLOG task); ``reject_finding`` records the reason ("dismiss"
no task). Both are idempotent per finding.
A finding carries no ``project_slug`` the way a roadmap item does (Periscope
reads the market, not a repo). The target project resolves to RoboCo's own
project (``settings.self_heal_project_slug``, the same fallback
``PeriscopeEngine._roboco_project`` already uses to anchor the exploration
task itself) — a market signal is process/strategy input about the org, not
about any one customer repo, and RoboCo is the only project every findings
consumer has in common.
"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from roboco.config import settings
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus, Team
from roboco.services.base import BaseService
from roboco.services.board_programs import learn_ref
from roboco.services.task import PERISCOPE_ITEM_SOURCE, PERISCOPE_SOURCE
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
_TERMINAL_ITEM_STATUSES = ("approved", "rejected")
@dataclass(frozen=True)
class MarketBriefFindingResult:
"""Outcome of an approve/reject call on one market-brief finding.
`status` is one of: approved, already_approved, rejected,
already_rejected, invalid_state.
"""
status: str
finding_id: str
materialized_task_id: str | None
detail: str
class PeriscopeService(BaseService):
"""Approve / reject findings within a completed Periscope market brief."""
service_name = "periscope_service"
async def approve_finding(
self, task_id: UUID, finding_id: str, *, created_by: UUID
) -> MarketBriefFindingResult | None:
"""Materialize one proposed finding as a Main-PM-owned root task.
Returns None when ``task_id`` carries no Periscope brief or
``finding_id`` does not exist on it. Idempotent: an already-approved
finding returns its stored materialized task id without creating a
duplicate. An already-rejected finding cannot be approved.
"""
task, payload, finding = await self._find_finding(task_id, finding_id)
if task is None or payload is None or finding is None:
return None
if finding["status"] == "approved":
return MarketBriefFindingResult(
status="already_approved",
finding_id=finding_id,
materialized_task_id=finding.get("materialized_task_id"),
detail="this finding was already approved",
)
if finding["status"] != "proposed":
return MarketBriefFindingResult(
status="invalid_state",
finding_id=finding_id,
materialized_task_id=None,
detail=(
f"finding is {finding['status']!r}, not proposed — cannot approve"
),
)
try:
new_task = await self._materialize(finding, created_by=created_by)
except ValueError as exc:
return MarketBriefFindingResult(
status="invalid_state",
finding_id=finding_id,
materialized_task_id=None,
detail=str(exc),
)
finding["status"] = "approved"
finding["materialized_task_id"] = str(new_task.id)
markers.set_market_brief(task, payload)
await self._record_learn(task, finding, "approved")
await self.session.flush()
return MarketBriefFindingResult(
status="approved",
finding_id=finding_id,
materialized_task_id=str(new_task.id),
detail="materialized as a Main-PM-owned task",
)
async def reject_finding(
self, task_id: UUID, finding_id: str, reason: str
) -> MarketBriefFindingResult | None:
"""Dismiss one proposed finding, recording the CEO's reason.
Idempotent: an already-rejected finding returns its stored reason
without re-recording. An already-approved finding cannot be
rejected (irreversible — a task already exists for it).
"""
task, payload, finding = await self._find_finding(task_id, finding_id)
if task is None or payload is None or finding is None:
return None
if finding["status"] == "rejected":
return MarketBriefFindingResult(
status="already_rejected",
finding_id=finding_id,
materialized_task_id=None,
detail="this finding was already dismissed",
)
if finding["status"] != "proposed":
return MarketBriefFindingResult(
status="invalid_state",
finding_id=finding_id,
materialized_task_id=finding.get("materialized_task_id"),
detail=(
f"finding is {finding['status']!r}, not proposed — cannot dismiss"
),
)
finding["status"] = "rejected"
finding["reject_reason"] = reason
markers.set_market_brief(task, payload)
await self._record_learn(task, finding, "rejected", reason)
await self.session.flush()
return MarketBriefFindingResult(
status="rejected",
finding_id=finding_id,
materialized_task_id=None,
detail="dismissed; feeds the next cycle's prompt",
)
async def _find_finding(
self, task_id: UUID, finding_id: str
) -> tuple[TaskTable | None, dict[str, Any] | None, dict[str, Any] | None]:
"""Resolve (exploration task, brief payload, one finding) or (None,
None, None). Deep-copies the stored marker before mutating it — see
``RoadmapService._find_item``'s identical dirty-check rationale.
A finding authored before this feature shipped carries no ``status``
key at all — ``setdefault`` treats it as ``proposed`` rather than
crashing on a missing key.
"""
from roboco.services.task import get_task_service
task = await get_task_service(self.session).get(task_id)
if task is None or task.source != PERISCOPE_SOURCE:
return None, None, None
stored = markers.get_market_brief(task)
if stored is None:
return None, None, None
payload = copy.deepcopy(stored)
finding = next(
(f for f in payload.get("findings", []) if f.get("id") == finding_id), None
)
if finding is None:
return None, None, None
finding.setdefault("status", "proposed")
return task, payload, finding
async def _materialize(
self, finding: dict[str, Any], *, created_by: UUID
) -> TaskTable:
"""Turn one approved finding into a real Main-PM-owned root task,
anchored on the RoboCo project (see module docstring for why).
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), matching
``TaskService.approve_and_start`` — a market signal has no natural
owning cell, so unlike ``RoadmapService._materialize`` there is no
per-item cell to preserve as a delegation hint."""
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.prompter import BatchPlacement, get_prompter_service
project = await self._roboco_project()
if project is None or project.id is None:
raise ValueError(
"the RoboCo project (settings.self_heal_project_slug) is not "
"resolvable — cannot anchor a materialized task"
)
draft = {
"title": f"Market signal: {finding['claim']}"[:200],
"objective": finding["claim"],
"notes": [
f"Relevance: {finding['relevance']}",
f"Source: {finding['source_url']}",
],
"acceptance_criteria": [
f"The market signal is addressed: {finding['claim']}",
"A note explains what changed in response and why.",
],
"project_id": str(project.id),
"team": Team.BACKEND.value,
"priority": 2,
"source": PERISCOPE_ITEM_SOURCE,
}
return await get_prompter_service(self.session).create_task_from_draft(
draft,
created_by,
status=TaskStatus.PENDING,
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
placement=BatchPlacement(team_override=Team.MAIN_PM),
)
async def _roboco_project(self) -> Any:
"""Mirrors ``PeriscopeEngine._roboco_project`` exactly — the same
fallback anchor a Periscope exploration task itself resolves against."""
from roboco.services.project import get_project_service
slug = (settings.self_heal_project_slug or "roboco-api").strip()
return await get_project_service(self.session).get_by_slug(slug)
async def _record_learn(
self,
task: TaskTable,
finding: dict[str, Any],
verdict: str,
reason: str | None = None,
) -> None:
"""Best-effort LEARN: a record_decision failure must never break the
CEO's approve/reject — mirrors ``RoadmapService._record_learn``.
``learn_ref`` expects a ``title``/``target_task_title`` field; a
finding carries neither, so it's wrapped with its ``claim`` under
``title`` rather than reinventing the truncation/fallback logic.
"""
try:
from roboco.services.board_programs import get_board_program_engine
await get_board_program_engine(self.session).record_decision(
"periscope",
learn_ref({"title": finding.get("claim")}),
verdict,
reason,
exploration_task_id=cast("UUID", task.id),
)
except Exception:
self.log.warning("periscope: LEARN record_decision failed (best-effort)")
def get_periscope_service(session: AsyncSession) -> PeriscopeService:
"""Construct a PeriscopeService bound to ``session``."""
return PeriscopeService(session)
+30 -14
View File
@@ -6,13 +6,19 @@ source); the Product Owner authors the hunt onto it via ``propose_bug_hunt``
(1-5 evidence-backed bug item drafts, persisted as a marker payload — see
``roboco.foundation.policy.content.markers.get_pest_hunt``). This service is
what the CEO-gated routes call: ``approve_item`` materializes one item as a
BACKLOG task (``source=pest_control``, via
``PrompterService.create_task_from_draft`` — CEO approval IS the
confirmation); ``reject_item`` records the reason. Once every item on the
cycle is terminal (approved/rejected) the exploration task itself completes.
Both actions are idempotent per item. Mirrors ``RoadmapService`` exactly,
minus the cycle-level theme goal roadmap carries (pest-hunt items stand
alone) and with ``rationale`` replaced by the required ``evidence`` field.
PENDING, Main-PM-owned root task (``source=pest_control``,
``assigned_to=main-pm``, via ``PrompterService.create_task_from_draft`` — CEO
approval IS the confirmation); ``reject_item`` records the reason. Once every
item on the cycle is terminal (approved/rejected) the exploration task itself
completes. Both actions are idempotent per item. Mirrors ``RoadmapService``
exactly, minus the cycle-level theme goal roadmap carries (pest-hunt items
stand alone) and with ``rationale`` replaced by the required ``evidence``
field.
A materialized item is NEVER an unowned BACKLOG task — see
``RoadmapService._materialize``'s docstring for why (nothing dispatches
BACKLOG, and a parentless root a cell PM claims directly bypasses the
Main-PM root / root->master PR / CEO approval gate).
"""
from __future__ import annotations
@@ -20,17 +26,16 @@ from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from roboco.foundation.policy.board_programs import PROGRAMS, project_participates
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus
from roboco.models.base import TaskStatus, Team
from roboco.services.base import BaseService
from roboco.services.board_programs import learn_ref
from roboco.services.task import PEST_CONTROL_ITEM_SOURCE, PEST_CONTROL_SOURCE
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
@@ -177,9 +182,14 @@ class PestControlService(BaseService):
async def _materialize(
self, item: dict[str, Any], *, created_by: UUID
) -> TaskTable:
"""Turn one approved item draft into a real BACKLOG task."""
"""Turn one approved item draft into a Main-PM-owned root task.
Mirrors ``RoadmapService._materialize`` — PENDING + main-pm and
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), not a
parentless BACKLOG task and not left on the item's own cell team; the
item's own cell survives as a Notes delegation hint instead."""
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.project import get_project_service
from roboco.services.prompter import get_prompter_service
from roboco.services.prompter import BatchPlacement, get_prompter_service
project = await get_project_service(self.session).get_by_slug(
item["project_slug"]
@@ -198,7 +208,11 @@ class PestControlService(BaseService):
draft = {
"title": item["title"],
"objective": item["description"],
"notes": [f"Evidence: {item['evidence']}"],
"notes": [
f"Evidence: {item['evidence']}",
f"Delegation hint: originated as a {item['team']} item — "
f"delegate into the {item['team']} cell.",
],
"acceptance_criteria": item["acceptance_criteria"],
"project_id": str(project.id),
"team": item["team"],
@@ -208,7 +222,9 @@ class PestControlService(BaseService):
return await get_prompter_service(self.session).create_task_from_draft(
draft,
created_by,
status=TaskStatus.BACKLOG,
status=TaskStatus.PENDING,
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
placement=BatchPlacement(team_override=Team.MAIN_PM),
)
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
+16 -1
View File
@@ -76,7 +76,17 @@ _MIN_MEGATASK_PROJECTS = 2
# "release_manager", which would even wedge the real release engine's
# one-open-proposal dedup).
_ALLOWED_DRAFT_SOURCES = frozenset(
{"prompter", "roadmap", "pest_control", "spackle", "mirror", "dogfood"}
{
"prompter",
"roadmap",
"pest_control",
"spackle",
"mirror",
"dogfood",
"periscope",
"sentinel",
"coroner",
}
)
# A draft whose per-cell map covers at least this many cells targets the ad-hoc
@@ -110,6 +120,11 @@ class BatchPlacement:
owning team for the whole batch; ``parent_task_id`` is the umbrella (or None
for the umbrella itself); ``batch_id`` is the shared batch identity; and
``sequence`` is the item's wave index.
``team_override`` alone (the other three left at their batch-less
defaults) is also the seam Board Program materialization uses — e.g.
``RoadmapService._materialize`` — to force ``team=Team.MAIN_PM`` on a
materialized coordination root without it being part of any real batch.
"""
parent_task_id: UUID | None = None
+42 -11
View File
@@ -6,10 +6,17 @@ the Product Owner authors the cycle onto it via ``propose_roadmap`` (a goal +
3-7 item drafts, persisted as a marker payload — see
``roboco.foundation.policy.content.markers.get_roadmap_cycle``). This service
is what the CEO-gated routes call: ``approve_item`` materializes one item as a
BACKLOG task (``source=roadmap``, via ``PrompterService.create_task_from_draft``
— CEO approval IS the confirmation); ``reject_item`` records the reason. Once
every item on the cycle is terminal (approved/rejected) the exploration task
itself completes. Both actions are idempotent per item.
PENDING, Main-PM-owned root task (``source=roadmap``, ``assigned_to=main-pm``,
via ``PrompterService.create_task_from_draft`` — CEO approval IS the
confirmation); ``reject_item`` records the reason. Once every item on the
cycle is terminal (approved/rejected) the exploration task itself completes.
Both actions are idempotent per item.
A materialized item is NEVER an unowned BACKLOG task: nothing dispatches
BACKLOG, and a parentless root a cell PM claims directly would resolve its
own merge target straight to the project's head rung on ``complete()``,
bypassing the Main-PM root, the root->master PR, and the CEO's approval gate
— see ``_materialize``'s docstring.
"""
from __future__ import annotations
@@ -17,17 +24,16 @@ from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from roboco.foundation.policy.board_programs import PROGRAMS, project_participates
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus
from roboco.models.base import TaskStatus, Team
from roboco.services.base import BaseService
from roboco.services.board_programs import learn_ref
from roboco.services.task import ROADMAP_ITEM_SOURCE, ROADMAP_SOURCE, get_task_service
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
@@ -177,9 +183,28 @@ class RoadmapService(BaseService):
async def _materialize(
self, item: dict[str, Any], *, created_by: UUID
) -> TaskTable:
"""Turn one approved item draft into a real BACKLOG task."""
"""Turn one approved item draft into a Main-PM-owned root task.
PENDING + ``assigned_to=main-pm`` — NOT a parentless BACKLOG task.
Nothing dispatches BACKLOG, and a parentless task a cell PM claims
and completes resolves its merge target (``resolve_parent_branch``)
straight to the project's head rung, bypassing the Main-PM root, the
root->master PR, and the CEO's approval gate (live proof: PRs
#703/#704 merged feature/{team}/... -> slave directly). Pre-assigning
the Main PM makes this a real coordination root that dispatches
immediately — ``team=Team.MAIN_PM`` (via ``BatchPlacement``'s
``team_override`` seam, the same knob a MegaTask batch uses), matching
``TaskService.approve_and_start`` exactly, since every "is this a
coordination root" check (``pr_fail``'s next-hint, the PR-gate steer,
delegate's wave-chain branch, the PR labeler) keys on ``team``, not
``assigned_to``. The item's own cell survives as a delegation hint in
the task's Notes (part of the composed description, which the Main
PM's spawn briefing renders verbatim) rather than in the ``team``
column — the same shape intake's "Approve & Start" produces.
"""
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.project import get_project_service
from roboco.services.prompter import get_prompter_service
from roboco.services.prompter import BatchPlacement, get_prompter_service
project = await get_project_service(self.session).get_by_slug(
item["project_slug"]
@@ -197,7 +222,11 @@ class RoadmapService(BaseService):
draft = {
"title": item["title"],
"objective": item["description"],
"notes": [f"Rationale: {item['rationale']}"],
"notes": [
f"Rationale: {item['rationale']}",
f"Delegation hint: originated as a {item['team']} item — "
f"delegate into the {item['team']} cell.",
],
"acceptance_criteria": item["acceptance_criteria"],
"project_id": str(project.id),
"team": item["team"],
@@ -207,7 +236,9 @@ class RoadmapService(BaseService):
return await get_prompter_service(self.session).create_task_from_draft(
draft,
created_by,
status=TaskStatus.BACKLOG,
status=TaskStatus.PENDING,
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
placement=BatchPlacement(team_override=Team.MAIN_PM),
)
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
+272
View File
@@ -0,0 +1,272 @@
"""SentinelService — the CEO's per-item approve/dismiss glue over a
completed Sentinel quality report.
The Sentinel engine opens a HELD exploration task (``board_sentinel``
source); the Auditor files ONE "state of quality" report onto it via
``propose_quality_report`` (a headline + 1-7 drift items, persisted as a
marker payload — see
``roboco.foundation.policy.content.markers.get_quality_report``) and the
exploration task completes in that same call — a report, not a per-item
queue.
Unlike the exploration task, each ITEM still carries its own
proposed/approved/rejected status the CEO decides on afterward — that is
what this service is for. ``approve_item`` materializes one item as a
PENDING, Main-PM-owned root task (``source=sentinel``, ``assigned_to=
main-pm`` — see ``RoadmapService._materialize``'s docstring for why never a
parentless BACKLOG task); ``reject_item`` records the reason ("dismiss"
no task). Both are idempotent per item. Mirrors ``PeriscopeService`` closely
— a Sentinel drift item already carries a machine-readable
``suggested_action``, used directly as the materialized task's acceptance
criterion.
A drift item carries no ``project_slug`` the way a roadmap item does
(Sentinel watches the org's own process — waivers, findings, conventions,
budget, docs drift — not one repo). The target project resolves to RoboCo's
own project (``settings.self_heal_project_slug``, the same fallback
``SentinelEngine._roboco_project`` already uses to anchor the exploration
task itself) — drift in RoboCo's own delivery process is, definitionally,
about RoboCo's own project.
"""
from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from roboco.config import settings
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus, TaskType, Team
from roboco.services.base import BaseService
from roboco.services.board_programs import learn_ref
from roboco.services.task import SENTINEL_ITEM_SOURCE, SENTINEL_SOURCE
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
# Sentinel items tagged "docs" materialize as documentation tasks — mirrors
# MirrorService's identical area->task_type override.
_DOCS_AREA = "docs"
@dataclass(frozen=True)
class QualityReportItemResult:
"""Outcome of an approve/reject call on one quality-report item.
`status` is one of: approved, already_approved, rejected,
already_rejected, invalid_state.
"""
status: str
item_id: str
materialized_task_id: str | None
detail: str
class SentinelService(BaseService):
"""Approve / reject items within a completed Sentinel quality report."""
service_name = "sentinel_service"
async def approve_item(
self, task_id: UUID, item_id: str, *, created_by: UUID
) -> QualityReportItemResult | None:
"""Materialize one proposed drift item as a Main-PM-owned root task.
Returns None when ``task_id`` carries no Sentinel report or
``item_id`` does not exist on it. Idempotent: an already-approved
item returns its stored materialized task id without creating a
duplicate. An already-rejected item cannot be approved.
"""
task, payload, item = await self._find_item(task_id, item_id)
if task is None or payload is None or item is None:
return None
if item["status"] == "approved":
return QualityReportItemResult(
status="already_approved",
item_id=item_id,
materialized_task_id=item.get("materialized_task_id"),
detail="this item was already approved",
)
if item["status"] != "proposed":
return QualityReportItemResult(
status="invalid_state",
item_id=item_id,
materialized_task_id=None,
detail=f"item is {item['status']!r}, not proposed — cannot approve",
)
try:
new_task = await self._materialize(item, created_by=created_by)
except ValueError as exc:
return QualityReportItemResult(
status="invalid_state",
item_id=item_id,
materialized_task_id=None,
detail=str(exc),
)
item["status"] = "approved"
item["materialized_task_id"] = str(new_task.id)
markers.set_quality_report(task, payload)
await self._record_learn(task, item, "approved")
await self.session.flush()
return QualityReportItemResult(
status="approved",
item_id=item_id,
materialized_task_id=str(new_task.id),
detail="materialized as a Main-PM-owned task",
)
async def reject_item(
self, task_id: UUID, item_id: str, reason: str
) -> QualityReportItemResult | None:
"""Dismiss one proposed drift item, recording the CEO's reason.
Idempotent: an already-rejected item returns its stored reason
without re-recording. An already-approved item cannot be rejected
(irreversible — a task already exists for it).
"""
task, payload, item = await self._find_item(task_id, item_id)
if task is None or payload is None or item is None:
return None
if item["status"] == "rejected":
return QualityReportItemResult(
status="already_rejected",
item_id=item_id,
materialized_task_id=None,
detail="this item was already dismissed",
)
if item["status"] != "proposed":
return QualityReportItemResult(
status="invalid_state",
item_id=item_id,
materialized_task_id=item.get("materialized_task_id"),
detail=f"item is {item['status']!r}, not proposed — cannot dismiss",
)
item["status"] = "rejected"
item["reject_reason"] = reason
markers.set_quality_report(task, payload)
await self._record_learn(task, item, "rejected", reason)
await self.session.flush()
return QualityReportItemResult(
status="rejected",
item_id=item_id,
materialized_task_id=None,
detail="dismissed; feeds the next cycle's prompt",
)
async def _find_item(
self, task_id: UUID, item_id: str
) -> tuple[TaskTable | None, dict[str, Any] | None, dict[str, Any] | None]:
"""Resolve (exploration task, report payload, one item) or (None,
None, None). Deep-copies the stored marker before mutating it — see
``RoadmapService._find_item``'s identical dirty-check rationale.
An item authored before this feature shipped carries no ``status``
key at all — ``setdefault`` treats it as ``proposed`` rather than
crashing on a missing key.
"""
from roboco.services.task import get_task_service
task = await get_task_service(self.session).get(task_id)
if task is None or task.source != SENTINEL_SOURCE:
return None, None, None
stored = markers.get_quality_report(task)
if stored is None:
return None, None, None
payload = copy.deepcopy(stored)
item = next(
(it for it in payload.get("items", []) if it.get("id") == item_id), None
)
if item is None:
return None, None, None
item.setdefault("status", "proposed")
return task, payload, item
async def _materialize(
self, item: dict[str, Any], *, created_by: UUID
) -> TaskTable:
"""Turn one approved drift item into a real Main-PM-owned root task,
anchored on the RoboCo project (see module docstring for why).
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), matching
``TaskService.approve_and_start`` — a process/quality drift item has no
natural owning cell, so unlike ``RoadmapService._materialize`` there is
no per-item cell to preserve as a delegation hint."""
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.prompter import BatchPlacement, get_prompter_service
project = await self._roboco_project()
if project is None or project.id is None:
raise ValueError(
"the RoboCo project (settings.self_heal_project_slug) is not "
"resolvable — cannot anchor a materialized task"
)
draft = {
"title": f"Sentinel [{item['area']}]: {item['suggested_action']}"[:200],
"objective": item["suggested_action"],
"notes": [
f"Observation: {item['observation']}",
f"Evidence: {item['evidence']}",
],
"acceptance_criteria": [
item["suggested_action"],
f"Addresses the drift observed: {item['observation']}",
],
"project_id": str(project.id),
"team": Team.BACKEND.value,
"priority": 2,
"source": SENTINEL_ITEM_SOURCE,
}
if item.get("area") == _DOCS_AREA:
draft["task_type"] = TaskType.DOCUMENTATION.value
return await get_prompter_service(self.session).create_task_from_draft(
draft,
created_by,
status=TaskStatus.PENDING,
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
placement=BatchPlacement(team_override=Team.MAIN_PM),
)
async def _roboco_project(self) -> Any:
"""Mirrors ``SentinelEngine._roboco_project`` exactly — the same
fallback anchor a Sentinel exploration task itself resolves against."""
from roboco.services.project import get_project_service
slug = (settings.self_heal_project_slug or "roboco-api").strip()
return await get_project_service(self.session).get_by_slug(slug)
async def _record_learn(
self,
task: TaskTable,
item: dict[str, Any],
verdict: str,
reason: str | None = None,
) -> None:
"""Best-effort LEARN: a record_decision failure must never break the
CEO's approve/reject — mirrors ``RoadmapService._record_learn``.
``learn_ref`` expects a ``title``/``target_task_title`` field; a
drift item carries neither, so it's wrapped with its
``suggested_action`` under ``title`` rather than reinventing the
truncation/fallback logic.
"""
try:
from roboco.services.board_programs import get_board_program_engine
await get_board_program_engine(self.session).record_decision(
"sentinel",
learn_ref({"title": item.get("suggested_action")}),
verdict,
reason,
exploration_task_id=cast("UUID", task.id),
)
except Exception:
self.log.warning("sentinel: LEARN record_decision failed (best-effort)")
def get_sentinel_service(session: AsyncSession) -> SentinelService:
"""Construct a SentinelService bound to ``session``."""
return SentinelService(session)
+25 -12
View File
@@ -6,11 +6,14 @@ the Product Owner authors the gap-fill audit onto it via ``propose_gap_fill``
(1-5 evidence-backed item drafts, persisted as a marker payload — see
``roboco.foundation.policy.content.markers.get_gap_fill``). This service is
what the CEO-gated routes call: ``approve_item`` materializes one item as a
BACKLOG task (``source=spackle``, via ``PrompterService.
create_task_from_draft`` — CEO approval IS the confirmation); ``reject_item``
records the reason. Once every item on the cycle is terminal
(approved/rejected) the exploration task itself completes. Both actions are
idempotent per item. Mirrors ``PestControlService`` exactly.
PENDING, Main-PM-owned root task (``source=spackle``, ``assigned_to=main-pm``,
via ``PrompterService.create_task_from_draft`` — CEO approval IS the
confirmation); ``reject_item`` records the reason. Once every item on the
cycle is terminal (approved/rejected) the exploration task itself completes.
Both actions are idempotent per item. Mirrors ``PestControlService`` exactly.
A materialized item is NEVER an unowned BACKLOG task — see
``RoadmapService._materialize``'s docstring for why.
"""
from __future__ import annotations
@@ -18,17 +21,16 @@ from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from roboco.foundation.policy.board_programs import PROGRAMS, project_participates
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus
from roboco.models.base import TaskStatus, Team
from roboco.services.base import BaseService
from roboco.services.board_programs import learn_ref
from roboco.services.task import SPACKLE_ITEM_SOURCE, SPACKLE_SOURCE
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import TaskTable
@@ -175,9 +177,14 @@ class SpackleService(BaseService):
async def _materialize(
self, item: dict[str, Any], *, created_by: UUID
) -> TaskTable:
"""Turn one approved item draft into a real BACKLOG task."""
"""Turn one approved item draft into a Main-PM-owned root task.
Mirrors ``RoadmapService._materialize`` — PENDING + main-pm and
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), not a
parentless BACKLOG task and not left on the item's own cell team; the
item's own cell survives as a Notes delegation hint instead."""
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.project import get_project_service
from roboco.services.prompter import get_prompter_service
from roboco.services.prompter import BatchPlacement, get_prompter_service
project = await get_project_service(self.session).get_by_slug(
item["project_slug"]
@@ -196,7 +203,11 @@ class SpackleService(BaseService):
draft = {
"title": item["title"],
"objective": item["description"],
"notes": [f"Evidence: {item['evidence']}"],
"notes": [
f"Evidence: {item['evidence']}",
f"Delegation hint: originated as a {item['team']} item — "
f"delegate into the {item['team']} cell.",
],
"acceptance_criteria": item["acceptance_criteria"],
"project_id": str(project.id),
"team": item["team"],
@@ -206,7 +217,9 @@ class SpackleService(BaseService):
return await get_prompter_service(self.session).create_task_from_draft(
draft,
created_by,
status=TaskStatus.BACKLOG,
status=TaskStatus.PENDING,
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
placement=BatchPlacement(team_override=Team.MAIN_PM),
)
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
+33 -9
View File
@@ -746,20 +746,38 @@ PEST_CONTROL_ITEM_SOURCE = "pest_control"
# (competitors, adjacent-tool releases, positioning shifts) and author a brief
# via the ``propose_market_brief`` content verb. Org-scoped (no project
# targeting — it reads the market, not a repo) and, like X_FEATURE_
# EXPLORATION_SOURCE, complete-at-propose: a report has no per-item CEO
# decision, so no separate materialized-item source exists for it.
# EXPLORATION_SOURCE, complete-at-propose: the exploration task itself
# completes the moment the brief is filed, but each cited finding still
# carries its OWN proposed/approved/rejected status the CEO decides
# per-finding after the fact (source=PERISCOPE_ITEM_SOURCE below) — the report
# and the per-item queue are orthogonal, unlike roadmap/pest-control where
# they're the same open-vs-closed task.
PERISCOPE_SOURCE = "board_periscope"
# Source tag stamped on a task MATERIALIZED from an approved Periscope
# finding (distinct from PERISCOPE_SOURCE, which tags the already-completed
# exploration task the finding lives on).
PERISCOPE_ITEM_SOURCE = "periscope"
# Source tag for a Coroner (Board Program) postmortem-exploration task: the
# EVENT-triggered autopsy the Auditor authors (spec §4) when an incident task
# bounces >=3x, is cancelled after work started, or is budget-blocked.
# Dispatched (one-shot Auditor spawn), never rides the delivery lifecycle, and
# — unlike ROADMAP_SOURCE/PEST_CONTROL_SOURCE — has no separate materialized-
# item source: a single ``propose_postmortem`` call completes it in place
# (mirrors X_FEATURE_EXPLORATION_SOURCE's atomic-complete shape, not the
# stays-open-for-per-item-decisions roadmap/pest-control shape).
# Dispatched (one-shot Auditor spawn), never rides the delivery lifecycle.
# The exploration task completes atomically the moment ``propose_postmortem``
# lands (mirrors X_FEATURE_EXPLORATION_SOURCE's atomic-complete shape, not the
# stays-open-for-per-item-decisions roadmap/pest-control shape) — but unlike
# a report, a postmortem's single ``process_change`` still carries its own
# proposed/approved/rejected status for the CEO's after-the-fact decision
# (source=CORONER_ITEM_SOURCE below), except when its kind is "playbook"
# (already routed straight into the playbook curation queue, nothing left to
# decide here).
CORONER_SOURCE = "board_coroner"
# Source tag stamped on a task MATERIALIZED from an approved Coroner
# process-change (distinct from CORONER_SOURCE, which tags the
# already-completed postmortem exploration task it lives on).
CORONER_ITEM_SOURCE = "coroner"
# Source tag for a Scales (Board Program) portfolio-rebalance exploration
# cycle: a PENDING task the scales engine opens for the Product Owner to
# review the live backlog against the charter and author re-priority /
@@ -822,10 +840,16 @@ async def _fire_coroner_bounce_hook(task_id: UUID) -> None:
# (waiver-accumulation trends, conventions-violation hotspots, docs/map
# staleness, budget anomalies) and file ONE "state of quality" report via the
# ``propose_quality_report`` content verb. Org-scoped (no project targeting)
# and, like PERISCOPE_SOURCE, complete-at-propose: a report has no per-item
# CEO decision, so no separate materialized-item source exists for it.
# and, like PERISCOPE_SOURCE, complete-at-propose — but each drift item still
# carries its own proposed/approved/rejected status for the CEO's per-item
# decision after the report is filed (source=SENTINEL_ITEM_SOURCE below).
SENTINEL_SOURCE = "board_sentinel"
# Source tag stamped on a task MATERIALIZED from an approved Sentinel drift
# item (distinct from SENTINEL_SOURCE, which tags the already-completed
# exploration task the item lives on).
SENTINEL_ITEM_SOURCE = "sentinel"
# Source tag for a Spackle (Board Program) exploration cycle: a PENDING task
# the spackle engine opens for the Product Owner to audit an opted-in
# project's half-shipped surface area (API routes with no panel surface and
+15 -3
View File
@@ -40,14 +40,17 @@ def seed_company(stack: E2EStack) -> Company:
return _COMPANY_CACHE["company"]
from roboco.db.tables import AgentTable
from roboco.foundation import identity as _foundation
from roboco.models import AgentRole, AgentStatus, Team
out = Company()
async def _run(session: AsyncSession) -> None:
def agent(slug: str, role: AgentRole, team: Team | None) -> AgentTable:
def agent(
slug: str, role: AgentRole, team: Team | None, *, agent_id: Any = None
) -> AgentTable:
row = AgentTable(
id=uuid4(),
id=agent_id or uuid4(),
name=slug,
slug=slug,
role=role,
@@ -66,7 +69,16 @@ def seed_company(stack: E2EStack) -> Company:
qa = agent("be-qa", AgentRole.QA, Team.BACKEND)
doc = agent("be-doc", AgentRole.DOCUMENTER, Team.BACKEND)
cell_pm = agent("be-pm", AgentRole.CELL_PM, Team.BACKEND)
main_pm = agent("main-pm", AgentRole.MAIN_PM, None)
# The canonical fixed UUID, not a random one: RoadmapService et al.'s
# per-item materialize (and the MegaTask main_pm-route batch confirm)
# hardcode AGENT_UUIDS["main-pm"] as the owning assignee, an FK to a
# real agents row — the same identity production seeding uses.
main_pm = agent(
"main-pm",
AgentRole.MAIN_PM,
None,
agent_id=_foundation.AGENTS["main-pm"].uuid,
)
reviewer = agent("pr-reviewer-1", AgentRole.PR_REVIEWER, None)
ceo = agent("ceo", AgentRole.CEO, None)
hom = agent("head-marketing", AgentRole.HEAD_MARKETING, Team.BOARD)
+6
View File
@@ -57,6 +57,12 @@ def _seed_system_hom_ceo_and_project(stack: E2EStack) -> str:
Team.BOARD,
),
(_foundation.AGENTS["ceo"].uuid, "ceo", AgentRole.CEO, None),
(
_foundation.AGENTS["main-pm"].uuid,
"main-pm",
AgentRole.MAIN_PM,
Team.MAIN_PM,
),
):
if await session.get(AgentTable, agent_uuid) is not None:
continue
+36 -2
View File
@@ -25,6 +25,7 @@ from sqlalchemy import update
_SEED_GIT_URL = "https://example.com/roboco-api-dogfood.git"
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -74,10 +75,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
await session.flush()
async def _seed_main_pm(session: AsyncSession) -> None:
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
assigns this id to the materialized task (an FK to ``agents``). The
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
other seed helpers here use): ``TaskService.approve_and_start`` and
other call sites resolve the Main PM by an EXACT slug lookup, and this
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
here would permanently squat that id with an unresolvable row for every
other test in the shared suite run."""
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
return
session.add(
AgentTable(
id=MAIN_PM_UUID,
name="main-pm",
slug="main-pm",
role=AgentRole.MAIN_PM,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
await _seed_ceo(session)
await _seed_main_pm(session)
project = ProjectTable(
id=uuid4(),
name="RoboCo API",
@@ -192,9 +223,10 @@ async def test_list_cycles_returns_authored_cycle(
@pytest.mark.asyncio
async def test_approve_item_materializes_backlog_task(
async def test_approve_item_materializes_main_pm_owned_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""Defect fix: see test_roadmap_routes.py's identical assertion update."""
task, _project = await _seed_cycle(db_session)
resp = await ceo_client.post(f"/api/dogfood/cycles/{task.id}/items/item-0/approve")
assert resp.status_code == HTTPStatus.OK
@@ -204,7 +236,9 @@ async def test_approve_item_materializes_backlog_task(
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
assert materialized is not None
assert materialized.status == TaskStatus.BACKLOG
assert materialized.status == TaskStatus.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
@pytest.mark.asyncio
+14 -5
View File
@@ -35,6 +35,7 @@ from roboco.models.base import (
TaskType,
Team,
)
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.task import TaskService
@@ -825,8 +826,16 @@ async def test_pm_escalate_to_ceo_path(
project = lifecycle_setup["project"]
system_agent = lifecycle_setup["system_agent"]
main_pm_agent = AgentTable(
id=uuid4(),
# Keyed on the fixed foundation UUID + an existence check (mirrors
# test_task_service_transitions.py's identical seeding pattern): the
# cross-test-shared DB already has other tests seeding this exact
# "main-pm" slug, and an unconditional insert with a fresh random id
# would collide on the slug's unique index.
main_pm_id = UUID(AGENT_UUIDS["main-pm"])
if await db_session.get(AgentTable, main_pm_id) is None:
db_session.add(
AgentTable(
id=main_pm_id,
name="Main PM",
slug="main-pm",
role=AgentRole.MAIN_PM,
@@ -838,7 +847,7 @@ async def test_pm_escalate_to_ceo_path(
permissions={},
metrics={},
)
db_session.add(main_pm_agent)
)
await db_session.flush()
del project, system_agent # only needed for fixture wiring above.
@@ -849,7 +858,7 @@ async def test_pm_escalate_to_ceo_path(
task.qa_verified = True
task.docs_complete = True
task.parent_task_id = None # explicit — escalate_to_ceo refuses subtasks.
task.assigned_to = main_pm_agent.id
task.assigned_to = main_pm_id
task.commits = [
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
]
@@ -862,7 +871,7 @@ async def test_pm_escalate_to_ceo_path(
# cast for mypy under the project's strict config — the values are
# already real ``uuid.UUID`` at runtime.
env = await c.complete(
UUID(str(main_pm_agent.id)),
main_pm_id,
UUID(str(task.id)),
notes="Root task ready for CEO approval — escalating.",
)
+38 -2
View File
@@ -25,6 +25,7 @@ from sqlalchemy import update
_SEED_GIT_URL = "https://example.com/backend-svc-mirror.git"
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -74,10 +75,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
await session.flush()
async def _seed_main_pm(session: AsyncSession) -> None:
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
assigns this id to the materialized task (an FK to ``agents``). The
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
other seed helpers here use): ``TaskService.approve_and_start`` and
other call sites resolve the Main PM by an EXACT slug lookup, and this
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
here would permanently squat that id with an unresolvable row for every
other test in the shared suite run."""
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
return
session.add(
AgentTable(
id=MAIN_PM_UUID,
name="main-pm",
slug="main-pm",
role=AgentRole.MAIN_PM,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
hom = await _seed_agent(session, AgentRole.HEAD_MARKETING, "head-marketing")
await _seed_ceo(session)
await _seed_main_pm(session)
project = ProjectTable(
id=uuid4(),
name="Backend Service",
@@ -192,9 +223,12 @@ async def test_list_cycles_returns_authored_cycle(
@pytest.mark.asyncio
async def test_approve_item_materializes_backlog_task(
async def test_approve_item_materializes_main_pm_owned_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""Defect fix: see test_roadmap_routes.py's identical assertion update.
Documentation type is untouched by the main-pm code->planning coercion
(that only retypes ``code``, never ``documentation``)."""
task, _project = await _seed_cycle(db_session)
resp = await ceo_client.post(f"/api/mirror/cycles/{task.id}/items/item-0/approve")
assert resp.status_code == HTTPStatus.OK
@@ -204,7 +238,9 @@ async def test_approve_item_materializes_backlog_task(
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
assert materialized is not None
assert materialized.status == TaskStatus.BACKLOG
assert materialized.status == TaskStatus.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
assert materialized.task_type == TaskType.DOCUMENTATION
+36 -2
View File
@@ -25,6 +25,7 @@ from sqlalchemy import update
_SEED_GIT_URL = "https://example.com/backend-svc.git"
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -74,10 +75,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
await session.flush()
async def _seed_main_pm(session: AsyncSession) -> None:
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
assigns this id to the materialized task (an FK to ``agents``). The
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
other seed helpers here use): ``TaskService.approve_and_start`` and
other call sites resolve the Main PM by an EXACT slug lookup, and this
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
here would permanently squat that id with an unresolvable row for every
other test in the shared suite run."""
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
return
session.add(
AgentTable(
id=MAIN_PM_UUID,
name="main-pm",
slug="main-pm",
role=AgentRole.MAIN_PM,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
await _seed_ceo(session)
await _seed_main_pm(session)
project = ProjectTable(
id=uuid4(),
name="Backend Service",
@@ -190,9 +221,10 @@ async def test_list_cycles_returns_authored_cycle(
@pytest.mark.asyncio
async def test_approve_item_materializes_backlog_task(
async def test_approve_item_materializes_main_pm_owned_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""Defect fix: see test_roadmap_routes.py's identical assertion update."""
task, _project = await _seed_cycle(db_session)
resp = await ceo_client.post(
f"/api/pest-control/cycles/{task.id}/items/item-0/approve"
@@ -204,7 +236,9 @@ async def test_approve_item_materializes_backlog_task(
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
assert materialized is not None
assert materialized.status == TaskStatus.BACKLOG
assert materialized.status == TaskStatus.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
@pytest.mark.asyncio
+23 -3
View File
@@ -2,13 +2,14 @@ from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
from uuid import uuid4
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import TaskNature, TaskStatus, TaskType
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.base import NotFoundError
from roboco.services.prompter import (
PrompterService,
@@ -138,9 +139,28 @@ async def redraft_setup(db_session: AsyncSession) -> AsyncIterator[dict]:
metrics={},
)
main_pm = _agent("main-pm", AgentRole.MAIN_PM)
# merge() with the fixed AGENT_UUIDS id: idempotent whether or not another
# test already committed this exact "main-pm"-slugged row on the shared
# session-scoped test DB (mirrors test_prompter.py's identical upsert) —
# an unconditional insert with a fresh random id here would collide with
# any other test's row on the slug's unique index.
main_pm = await db_session.merge(
AgentTable(
id=UUID(AGENT_UUIDS["main-pm"]),
name="main-pm",
slug="main-pm",
role=AgentRole.MAIN_PM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
po = _agent(f"product-owner-{uuid4().hex[:4]}", AgentRole.PRODUCT_OWNER)
db_session.add_all([main_pm, po])
db_session.add(po)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
+39 -2
View File
@@ -21,6 +21,7 @@ from roboco.models.permissions import AgentContext
from roboco.services.task import ROADMAP_SOURCE
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -70,10 +71,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
await session.flush()
async def _seed_main_pm(session: AsyncSession) -> None:
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
assigns this id to the materialized task (an FK to ``agents``). The
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
other seed helpers here use): ``TaskService.approve_and_start`` and
other call sites resolve the Main PM by an EXACT slug lookup, and this
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
here would permanently squat that id with an unresolvable row for every
other test in the shared suite run."""
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
return
session.add(
AgentTable(
id=MAIN_PM_UUID,
name="main-pm",
slug="main-pm",
role=AgentRole.MAIN_PM,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
await _seed_ceo(session)
await _seed_main_pm(session)
project = ProjectTable(
id=uuid4(),
name="Backend Service",
@@ -170,9 +201,13 @@ async def test_list_cycles_returns_authored_cycle(
@pytest.mark.asyncio
async def test_approve_item_materializes_backlog_task(
async def test_approve_item_materializes_main_pm_owned_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""Defect fix (#703/#704): approval used to materialize an unowned
BACKLOG task; see test_roadmap_service.py's identical assertion update
for the full rationale. It now materializes PENDING + assigned_to=
main-pm."""
task, _project = await _seed_cycle(db_session)
resp = await ceo_client.post(f"/api/roadmap/cycles/{task.id}/items/item-0/approve")
assert resp.status_code == HTTPStatus.OK
@@ -182,7 +217,9 @@ async def test_approve_item_materializes_backlog_task(
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
assert materialized is not None
assert materialized.status == TaskStatus.BACKLOG
assert materialized.status == TaskStatus.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
@pytest.mark.asyncio
+36 -2
View File
@@ -25,6 +25,7 @@ from sqlalchemy import update
_SEED_GIT_URL = "https://example.com/backend-svc-spackle.git"
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -74,10 +75,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
await session.flush()
async def _seed_main_pm(session: AsyncSession) -> None:
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
assigns this id to the materialized task (an FK to ``agents``). The
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
other seed helpers here use): ``TaskService.approve_and_start`` and
other call sites resolve the Main PM by an EXACT slug lookup, and this
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
here would permanently squat that id with an unresolvable row for every
other test in the shared suite run."""
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
return
session.add(
AgentTable(
id=MAIN_PM_UUID,
name="main-pm",
slug="main-pm",
role=AgentRole.MAIN_PM,
team=Team.MAIN_PM,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
await _seed_ceo(session)
await _seed_main_pm(session)
project = ProjectTable(
id=uuid4(),
name="Backend Service",
@@ -192,9 +223,10 @@ async def test_list_cycles_returns_authored_cycle(
@pytest.mark.asyncio
async def test_approve_item_materializes_backlog_task(
async def test_approve_item_materializes_main_pm_owned_task(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""Defect fix: see test_roadmap_routes.py's identical assertion update."""
task, _project = await _seed_cycle(db_session)
resp = await ceo_client.post(f"/api/spackle/cycles/{task.id}/items/item-0/approve")
assert resp.status_code == HTTPStatus.OK
@@ -204,7 +236,9 @@ async def test_approve_item_materializes_backlog_task(
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
assert materialized is not None
assert materialized.status == TaskStatus.BACKLOG
assert materialized.status == TaskStatus.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
@pytest.mark.asyncio
@@ -2048,9 +2048,17 @@ async def test_activate_batch_root_subtasks_retypes_code_to_planning(
when flipping team to main_pm, mirroring approve_and_start, or the
main_pm+code combo recurs."""
svc = task_setup["svc"]
# approve_and_start resolves the main-pm agent by slug — seed it.
main_pm = AgentTable(
id=uuid4(),
# approve_and_start resolves the main-pm agent by slug — seed it. Keyed
# on the fixed foundation UUID + an existence check (mirrors
# test_ceo_reject_routes_coordination_task_to_main_pm above): the
# cross-test-shared DB already has other tests seeding this exact
# "main-pm" slug, and a second unconditional insert with a fresh random
# id would collide on the slug's unique index.
main_pm_id = UUID(AGENT_UUIDS["main-pm"])
if await db_session.get(AgentTable, main_pm_id) is None:
db_session.add(
AgentTable(
id=main_pm_id,
name="Main PM",
slug="main-pm",
role=AgentRole.MAIN_PM,
@@ -2062,7 +2070,7 @@ async def test_activate_batch_root_subtasks_retypes_code_to_planning(
permissions={},
metrics={},
)
db_session.add(main_pm)
)
await db_session.flush()
batch = uuid4()
@@ -200,6 +200,10 @@ async def test_propose_postmortem_completes_the_task_and_stamps_marker() -> None
assert payload is not None
assert payload["failed_stage"] == "awaiting_qa"
assert payload["process_change"]["kind"] == "prompt_fix"
# A non-playbook process change stays "proposed" — the CEO's per-item
# approve/dismiss decision (CoronerService) is still open.
assert payload["process_change"]["status"] == "proposed"
assert payload["process_change"]["materialized_task_id"] is None
assert payload["playbook_id"] is None
assert task.status == TaskStatus.COMPLETED
@@ -244,6 +248,9 @@ async def test_propose_postmortem_drafts_playbook_when_kind_is_playbook() -> Non
playbook_svc.draft.assert_awaited_once()
payload = engine.complete_with_postmortem.await_args.args[1]
assert payload["playbook_id"] == str(drafted.id)
# A "playbook" kind already routed into the curation queue above —
# CoronerService refuses to act on it (see its own test module).
assert payload["process_change"]["status"] == "not_applicable"
@pytest.mark.asyncio
@@ -424,6 +424,11 @@ async def test_propose_market_brief_persists_and_completes_the_exploration_task(
assert payload["headline"] == "A rival tool shipped agentic PR review this week"
assert len(payload["findings"]) == len(findings)
assert payload["findings"][0]["id"] == "finding-0"
# Each finding still carries its own per-item CEO decision (Periscope
# Service.approve_finding/reject_finding) even though the exploration
# task completes here.
assert payload["findings"][0]["status"] == "proposed"
assert payload["findings"][0]["materialized_task_id"] is None
assert payload["threats"] == ["Feature parity gap"]
assert payload["opportunities"] == ["Lean into structured findings"]
assert payload["positioning_note"] == "Emphasize the findings ledger in messaging"
@@ -393,6 +393,11 @@ async def test_propose_quality_report_persists_and_completes_the_exploration_tas
assert len(payload["items"]) == len(items)
assert payload["items"][0]["id"] == "item-0"
assert payload["items"][0]["area"] == "waivers"
# Each item still carries its own per-item CEO decision (SentinelService.
# approve_item/reject_item) even though the exploration task completes
# here.
assert payload["items"][0]["status"] == "proposed"
assert payload["items"][0]["materialized_task_id"] is None
assert (
payload["overall_assessment"]
== "Drift is concentrated in one hotspot, not systemic"
+516
View File
@@ -0,0 +1,516 @@
"""CoronerService coverage: approve materializes the postmortem's ONE
process change as a Main-PM-owned root task (idempotent), reject records a
reason (idempotent). Unlike Periscope/Sentinel there is no item id a
postmortem is one process change, not a list and the target project
resolves against the INCIDENT task's own project (falling back to RoboCo's),
not a per-item project_slug.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.config import settings as cfg
from roboco.db.tables import (
AgentTable,
BoardProgramCycleTable,
ProjectTable,
SystemSettingTable,
TaskTable,
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
from roboco.models.base import TaskNature as TN
from roboco.models.base import TaskStatus as TS
from roboco.models.base import TaskType as TT
from roboco.services import board_programs as bp_module
from roboco.services.coroner_service import CoronerService, get_coroner_service
from roboco.services.task import CORONER_ITEM_SOURCE, CORONER_SOURCE
from sqlalchemy import delete, select, update
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
AUDITOR_UUID = _foundation.AGENTS["auditor"].uuid
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
ROBOCO_SLUG = "roboco-standin"
INCIDENT_SLUG = "customer-app"
ONE = 1
@pytest_asyncio.fixture(autouse=True)
async def _purge_board_program_pollution(db_session: AsyncSession) -> None:
"""See test_board_program_engine.py's identical fixture."""
await db_session.execute(
delete(SystemSettingTable).where(SystemSettingTable.key.like("board_program.%"))
)
await db_session.execute(delete(BoardProgramCycleTable))
await db_session.execute(
update(TaskTable)
.where(
TaskTable.source == CORONER_SOURCE,
TaskTable.status.notin_([TS.COMPLETED, TS.CANCELLED]),
)
.values(status=TS.CANCELLED)
)
await db_session.commit()
def _process_change(
*, kind: str = "conventions_rule", status: str | None = "proposed"
) -> dict:
change: dict[str, Any] = {
"kind": kind,
"description": "Add a venv-freshness check to make quality",
}
if status is not None:
change["status"] = status
change["reject_reason"] = None
change["materialized_task_id"] = None
return change
async def _seed_agents(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(AUDITOR_UUID, "auditor", AgentRole.AUDITOR, Team.BOARD),
(CEO_UUID, "ceo", AgentRole.CEO, None),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _seed_roboco_project(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> ProjectTable:
await _seed_agents(session)
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=ROBOCO_SLUG,
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
session.add(project)
await session.flush()
monkeypatch.setattr(cfg, "self_heal_project_slug", ROBOCO_SLUG)
return project
async def _seed_incident(session: AsyncSession, *, project: ProjectTable) -> TaskTable:
"""A real incident task on its OWN project/team — distinct from any
RoboCo fallback project, so a test asserting the incident's project/team
wins can't accidentally pass via the fallback instead."""
await _seed_agents(session)
incident = TaskTable(
id=uuid4(),
title="Fix worktree venv rot",
description="x",
acceptance_criteria=["x"],
status=TS.NEEDS_REVISION,
priority=2,
task_type=TT.CODE,
nature=TN.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
created_by=SYSTEM_UUID,
team=Team.FRONTEND,
source="manual",
confirmed_by_human=True,
project_id=project.id,
revision_count=3,
)
session.add(incident)
await session.flush()
return incident
async def _seed_incident_project(session: AsyncSession) -> ProjectTable:
await _seed_agents(session)
project = ProjectTable(
id=uuid4(),
name="Customer App",
slug=INCIDENT_SLUG,
git_url="https://example.com/customer-app.git",
assigned_cell=Team.FRONTEND,
created_by=SYSTEM_UUID,
)
session.add(project)
await session.flush()
return project
async def _seed_postmortem(
session: AsyncSession,
*,
incident: TaskTable | None,
process_change: dict | None = None,
postmortem_project_id: object | None = None,
) -> TaskTable:
await _seed_agents(session)
task = TaskTable(
id=uuid4(),
title="Coroner postmortem",
description="Autopsy the chronic task.",
acceptance_criteria=["propose_postmortem() called once"],
status=TS.COMPLETED,
priority=2,
task_type=TT.ADMINISTRATIVE,
nature=TN.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
created_by=SYSTEM_UUID,
assigned_to=AUDITOR_UUID,
team=Team.BOARD,
source=CORONER_SOURCE,
confirmed_by_human=False,
project_id=postmortem_project_id,
)
session.add(task)
await session.flush()
if incident is not None:
markers.set_coroner_incident(
task,
{
"incident_task_id": str(incident.id),
"kind": "bounced",
"revision_count": incident.revision_count or 0,
"title": incident.title,
},
)
markers.set_coroner_postmortem(
task,
{
"incident_summary": "the task bounced 3 times over a stale venv",
"root_cause": "the gate never verified the venv's dev extras",
"failed_stage": "awaiting_qa",
"process_change": process_change or _process_change(),
"playbook_id": None,
},
)
await session.flush()
return task
def _svc(session: AsyncSession) -> CoronerService:
return get_coroner_service(session)
def _id(task: TaskTable) -> UUID:
return cast("UUID", task.id)
@pytest.mark.asyncio
async def test_approve_materializes_main_pm_owned_task_on_incident_project(
db_session: AsyncSession,
) -> None:
"""The target project/team is the INCIDENT's own — not the postmortem
task's own project_id (left None here) and not a RoboCo fallback."""
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
assert result.materialized_task_id is not None
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.status == TS.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
assert materialized.source == CORONER_ITEM_SOURCE
assert materialized.project_id == incident_project.id
# team is forced to Team.MAIN_PM (not the incident's own cell) — see
# test_roadmap_service.py's identical assertion for why: every "is this
# a coordination root" consumer keys on team, not assigned_to.
assert materialized.team == Team.MAIN_PM
# main_pm can never own a code task — see test_roadmap_service.py's
# identical assertion for the coercion rationale.
assert materialized.task_type == TT.PLANNING
# The incident's own cell (Team.FRONTEND) survives as a Notes delegation
# hint instead of the materialized task's team column.
assert "frontend cell" in (materialized.description or "")
materialized.branch_name = "feature/main_pm/deadbeef"
hint = _next_hint_pr_fail(materialized)
assert "re-delegate" in hint
assert "do NOT re-submit" in hint
await db_session.refresh(task)
payload = markers.get_coroner_postmortem(task)
assert payload is not None
assert payload["process_change"]["status"] == "approved"
assert payload["process_change"]["materialized_task_id"] == str(
result.materialized_task_id
)
assert task.status == TS.COMPLETED
@pytest.mark.asyncio
async def test_approve_falls_back_to_roboco_project_when_incident_gone(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
roboco_project = await _seed_roboco_project(db_session, monkeypatch)
# coroner_incident references an incident id that no longer resolves.
task = await _seed_postmortem(db_session, incident=None)
markers.set_coroner_incident(
task,
{
"incident_task_id": str(uuid4()),
"kind": "cancelled",
"revision_count": 0,
"title": "gone",
},
)
await db_session.flush()
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.project_id == roboco_project.id
# team is forced to Team.MAIN_PM regardless of the fallback team
# (Team.BACKEND) _resolve_target reports when the incident is gone.
assert materialized.team == Team.MAIN_PM
assert "backend cell" in (materialized.description or "")
@pytest.mark.asyncio
async def test_approve_is_idempotent(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
svc = _svc(db_session)
first = await svc.approve_process_change(_id(task), created_by=CEO_UUID)
second = await svc.approve_process_change(_id(task), created_by=CEO_UUID)
assert first is not None
assert second is not None
assert second.status == "already_approved"
assert second.materialized_task_id == first.materialized_task_id
result = await db_session.execute(
select(TaskTable).where(TaskTable.source == CORONER_ITEM_SOURCE)
)
assert len(result.scalars().all()) == ONE
@pytest.mark.asyncio
async def test_reject_records_reason(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
result = await _svc(db_session).reject_process_change(
_id(task), "one-off incident, not worth a standing rule"
)
assert result is not None
assert result.status == "rejected"
await db_session.refresh(task)
payload = markers.get_coroner_postmortem(task)
assert payload is not None
assert payload["process_change"]["status"] == "rejected"
assert (
payload["process_change"]["reject_reason"]
== "one-off incident, not worth a standing rule"
)
@pytest.mark.asyncio
async def test_reject_is_idempotent(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
svc = _svc(db_session)
await svc.reject_process_change(_id(task), "reason one")
second = await svc.reject_process_change(_id(task), "reason two")
assert second is not None
assert second.status == "already_rejected"
@pytest.mark.asyncio
async def test_cannot_reject_an_approved_change(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
svc = _svc(db_session)
await svc.approve_process_change(_id(task), created_by=CEO_UUID)
result = await svc.reject_process_change(_id(task), "changed my mind")
assert result is not None
assert result.status == "invalid_state"
@pytest.mark.asyncio
async def test_cannot_approve_a_rejected_change(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
svc = _svc(db_session)
await svc.reject_process_change(_id(task), "not now")
result = await svc.approve_process_change(_id(task), created_by=CEO_UUID)
assert result is not None
assert result.status == "invalid_state"
@pytest.mark.asyncio
async def test_playbook_kind_refuses_approve(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(
db_session,
incident=incident,
process_change=_process_change(kind="playbook", status="not_applicable"),
)
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "invalid_state"
assert "already drafted as a playbook" in result.detail
@pytest.mark.asyncio
async def test_playbook_kind_refuses_reject(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(
db_session,
incident=incident,
process_change=_process_change(kind="playbook", status="not_applicable"),
)
result = await _svc(db_session).reject_process_change(_id(task), "no thanks")
assert result is not None
assert result.status == "invalid_state"
assert "already drafted as a playbook" in result.detail
@pytest.mark.asyncio
async def test_process_change_with_no_status_key_defaults_to_proposed(
db_session: AsyncSession,
) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
legacy_change = _process_change(status=None)
assert "status" not in legacy_change
task = await _seed_postmortem(
db_session, incident=incident, process_change=legacy_change
)
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
@pytest.mark.asyncio
async def test_approve_unresolvable_project_is_invalid_state(
db_session: AsyncSession,
) -> None:
"""Incident gone AND no RoboCo project seeded — fails cleanly instead of
guessing a project."""
task = await _seed_postmortem(db_session, incident=None)
markers.set_coroner_incident(
task,
{
"incident_task_id": str(uuid4()),
"kind": "cancelled",
"revision_count": 0,
"title": "gone",
},
)
await db_session.flush()
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "invalid_state"
assert "cannot anchor a materialized task" in result.detail
@pytest.mark.asyncio
async def test_unknown_task_returns_none(db_session: AsyncSession) -> None:
result = await _svc(db_session).approve_process_change(uuid4(), created_by=CEO_UUID)
assert result is None
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
session.add(
BoardProgramCycleTable(
program_key="coroner",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await session.flush()
@pytest.mark.asyncio
async def test_approve_records_learn_decision(db_session: AsyncSession) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
await _seed_cycle_ledger_row(db_session, task)
await _svc(db_session).approve_process_change(_id(task), created_by=CEO_UUID)
row = (
await db_session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.program_key == "coroner"
)
)
).scalar_one()
assert row.items_approved == ONE
decision = row.decisions[0]
assert decision["verdict"] == "approved"
assert decision["item_ref"] == "Add a venv-freshness check to make quality"
@pytest.mark.asyncio
async def test_approve_survives_learn_recording_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
incident_project = await _seed_incident_project(db_session)
incident = await _seed_incident(db_session, project=incident_project)
task = await _seed_postmortem(db_session, incident=incident)
await _seed_cycle_ledger_row(db_session, task)
async def _boom(_self: object, *_args: object, **_kwargs: object) -> None:
raise RuntimeError("learn boom")
monkeypatch.setattr(bp_module.BoardProgramEngine, "record_decision", _boom)
result = await _svc(db_session).approve_process_change(
_id(task), created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
+26 -3
View File
@@ -25,6 +25,7 @@ from roboco.db.tables import (
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
from roboco.models.base import (
AgentRole,
AgentStatus,
@@ -56,6 +57,7 @@ if TYPE_CHECKING:
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
PO_UUID = _foundation.AGENTS["product-owner"].uuid
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
ONE = 1
TWO = 2
@@ -109,6 +111,7 @@ async def _seed_agents(session: AsyncSession) -> None:
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
(CEO_UUID, "ceo", AgentRole.CEO, None),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
if await session.get(AgentTable, uuid) is None:
session.add(
@@ -187,7 +190,12 @@ def _id(task: TaskTable) -> UUID:
@pytest.mark.asyncio
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
async def test_approve_materializes_main_pm_owned_task(
db_session: AsyncSession,
) -> None:
"""Defect fix: mirrors test_roadmap_service.py's identical assertion
update approval materializes PENDING + assigned_to=main-pm, never an
unowned BACKLOG task (see RoadmapService._materialize's docstring)."""
await _seed_project(db_session, "frontend-app")
task = await _seed_cycle(db_session, project_slug="frontend-app")
result = await _svc(db_session).approve_item(
@@ -199,9 +207,24 @@ async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> No
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.status == TS.BACKLOG
assert materialized.status == TS.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
assert materialized.source == DOGFOOD_ITEM_SOURCE
assert materialized.team == Team.FRONTEND
# team is forced to Team.MAIN_PM (not the item's own cell) — see
# test_roadmap_service.py's identical assertion for why: every "is this
# a coordination root" consumer keys on team, not assigned_to.
assert materialized.team == Team.MAIN_PM
# main_pm can never own a code task — see test_roadmap_service.py's
# identical assertion for the coercion rationale.
assert materialized.task_type == TT.PLANNING
# The item's own cell survives as a Notes delegation hint instead.
assert "frontend cell" in (materialized.description or "")
materialized.branch_name = "feature/main_pm/deadbeef"
hint = _next_hint_pr_fail(materialized)
assert "re-delegate" in hint
assert "do NOT re-submit" in hint
await db_session.refresh(task)
payload = markers.get_friction_fixes(task)
+23 -3
View File
@@ -25,6 +25,7 @@ from roboco.db.tables import (
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
from roboco.models.base import (
AgentRole,
AgentStatus,
@@ -57,6 +58,7 @@ if TYPE_CHECKING:
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
HOM_UUID = _foundation.AGENTS["head-marketing"].uuid
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
ONE = 1
TWO = 2
@@ -111,6 +113,7 @@ async def _seed_agents(session: AsyncSession) -> None:
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(HOM_UUID, "head-marketing", AgentRole.HEAD_MARKETING, Team.BOARD),
(CEO_UUID, "ceo", AgentRole.CEO, None),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
if await session.get(AgentTable, uuid) is None:
session.add(
@@ -189,7 +192,12 @@ def _id(task: TaskTable) -> UUID:
@pytest.mark.asyncio
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
async def test_approve_materializes_main_pm_owned_task(
db_session: AsyncSession,
) -> None:
"""Defect fix: mirrors test_roadmap_service.py's identical assertion
update approval materializes PENDING + assigned_to=main-pm, never an
unowned BACKLOG task (see RoadmapService._materialize's docstring)."""
await _seed_project(db_session, "backend-svc")
task = await _seed_cycle(db_session, project_slug="backend-svc")
result = await _svc(db_session).approve_item(
@@ -201,10 +209,22 @@ async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> No
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.status == TS.BACKLOG
assert materialized.status == TS.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
assert materialized.source == MIRROR_ITEM_SOURCE
assert materialized.task_type == TT.DOCUMENTATION
assert materialized.team == Team.BACKEND
# team is forced to Team.MAIN_PM (not the item's own cell) — see
# test_roadmap_service.py's identical assertion for why: every "is this
# a coordination root" consumer keys on team, not assigned_to.
assert materialized.team == Team.MAIN_PM
# The item's own cell survives as a Notes delegation hint instead.
assert "backend cell" in (materialized.description or "")
materialized.branch_name = "feature/main_pm/deadbeef"
hint = _next_hint_pr_fail(materialized)
assert "re-delegate" in hint
assert "do NOT re-submit" in hint
await db_session.refresh(task)
payload = markers.get_messaging_fixes(task)
@@ -0,0 +1,410 @@
"""PeriscopeService coverage: per-finding approve materializes a Main-PM-
owned root task (idempotent), reject records a reason (idempotent). Unlike
RoadmapService the exploration task is ALREADY COMPLETED (complete-at-
propose) there is no cycle-completion transition to test, only the
finding's own status.
Mirrors test_roadmap_service.py's per-item shape, adapted for: no
project_slug on the item (resolves against the RoboCo project instead), and
a finding authored before this feature shipped carrying no status key at all
(setdefault, not a hard requirement).
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.config import settings as cfg
from roboco.db.tables import (
AgentTable,
BoardProgramCycleTable,
ProjectTable,
SystemSettingTable,
TaskTable,
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
from roboco.models.base import TaskNature as TN
from roboco.models.base import TaskStatus as TS
from roboco.models.base import TaskType as TT
from roboco.services import board_programs as bp_module
from roboco.services.periscope_service import PeriscopeService, get_periscope_service
from roboco.services.task import PERISCOPE_ITEM_SOURCE, PERISCOPE_SOURCE
from sqlalchemy import delete, select, update
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
HOM_UUID = _foundation.AGENTS["head-marketing"].uuid
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
ROBOCO_SLUG = "roboco-standin"
ONE = 1
@pytest_asyncio.fixture(autouse=True)
async def _purge_board_program_pollution(db_session: AsyncSession) -> None:
"""See test_board_program_engine.py's identical fixture."""
await db_session.execute(
delete(SystemSettingTable).where(SystemSettingTable.key.like("board_program.%"))
)
await db_session.execute(delete(BoardProgramCycleTable))
await db_session.execute(
update(TaskTable)
.where(
TaskTable.source == PERISCOPE_SOURCE,
TaskTable.status.notin_([TS.COMPLETED, TS.CANCELLED]),
)
.values(status=TS.CANCELLED)
)
await db_session.commit()
def _finding(idx: int, *, status: str | None = "proposed") -> dict:
finding: dict[str, Any] = {
"id": f"finding-{idx}",
"claim": f"Competitor {idx} shipped an autonomous review agent",
"source_url": f"https://example.com/competitor-{idx}",
"relevance": f"Overlaps our pr_reviewer role, finding {idx}",
}
if status is not None:
finding["status"] = status
finding["reject_reason"] = None
finding["materialized_task_id"] = None
return finding
async def _seed_agents(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(HOM_UUID, "head-marketing", AgentRole.HEAD_MARKETING, Team.BOARD),
(CEO_UUID, "ceo", AgentRole.CEO, None),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _seed_roboco_project(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> ProjectTable:
"""The org's own project — the RoboCo project resolution anchor every
Periscope/Sentinel/Coroner materialization falls back to. Mirrors
test_periscope_engine.py's ``_seed``/``_arm`` shape."""
await _seed_agents(session)
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=ROBOCO_SLUG,
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
session.add(project)
await session.flush()
monkeypatch.setattr(cfg, "self_heal_project_slug", ROBOCO_SLUG)
return project
async def _seed_brief(
session: AsyncSession, *, findings: list[dict] | None = None
) -> TaskTable:
await _seed_agents(session)
task = TaskTable(
id=uuid4(),
title="Periscope market-research cycle",
description="Research the market and file ONE brief.",
acceptance_criteria=["propose_market_brief() called once"],
status=TS.COMPLETED,
priority=2,
task_type=TT.ADMINISTRATIVE,
nature=TN.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
created_by=SYSTEM_UUID,
assigned_to=HOM_UUID,
team=Team.BOARD,
source=PERISCOPE_SOURCE,
confirmed_by_human=False,
)
session.add(task)
await session.flush()
findings = findings or [_finding(0), _finding(1)]
markers.set_market_brief(
task,
{
"headline": "A rival tool shipped agentic PR review",
"findings": findings,
"threats": [],
"opportunities": [],
"positioning_note": "",
"injection_hits": [],
},
)
await session.flush()
return task
def _svc(session: AsyncSession) -> PeriscopeService:
return get_periscope_service(session)
def _id(task: TaskTable) -> UUID:
return cast("UUID", task.id)
@pytest.mark.asyncio
async def test_approve_materializes_main_pm_owned_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_brief(db_session)
result = await _svc(db_session).approve_finding(
_id(task), "finding-0", created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
assert result.materialized_task_id is not None
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.status == TS.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
assert materialized.source == PERISCOPE_ITEM_SOURCE
# team is forced to Team.MAIN_PM — see test_roadmap_service.py's
# identical assertion for why: every "is this a coordination root"
# consumer keys on team, not assigned_to. A market signal has no natural
# owning cell (the prior Team.BACKEND was an arbitrary placeholder, not
# a real delegation hint), so there is no cell to preserve in Notes here.
assert materialized.team == Team.MAIN_PM
# main_pm can never own a code task (pm_cannot_own_code) — the intake
# coercion in create_task_from_draft retypes it to planning, the same
# shape a Main-PM coordination root always carries.
assert materialized.task_type == TT.PLANNING
materialized.branch_name = "feature/main_pm/deadbeef"
hint = _next_hint_pr_fail(materialized)
assert "re-delegate" in hint
assert "do NOT re-submit" in hint
await db_session.refresh(task)
payload = markers.get_market_brief(task)
assert payload is not None
finding0 = next(f for f in payload["findings"] if f["id"] == "finding-0")
assert finding0["status"] == "approved"
assert finding0["materialized_task_id"] == result.materialized_task_id
# The exploration task itself stays COMPLETED — approving a finding is
# orthogonal to the (already terminal) cycle.
assert task.status == TS.COMPLETED
@pytest.mark.asyncio
async def test_approve_is_idempotent(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_brief(db_session)
svc = _svc(db_session)
first = await svc.approve_finding(_id(task), "finding-0", created_by=CEO_UUID)
second = await svc.approve_finding(_id(task), "finding-0", created_by=CEO_UUID)
assert first is not None
assert second is not None
assert second.status == "already_approved"
assert second.materialized_task_id == first.materialized_task_id
result = await db_session.execute(
select(TaskTable).where(
TaskTable.source == PERISCOPE_ITEM_SOURCE,
TaskTable.title.like("Market signal:%"),
)
)
assert len(result.scalars().all()) == ONE
@pytest.mark.asyncio
async def test_reject_records_reason(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_brief(db_session)
result = await _svc(db_session).reject_finding(
_id(task), "finding-0", "not actionable this quarter"
)
assert result is not None
assert result.status == "rejected"
await db_session.refresh(task)
payload = markers.get_market_brief(task)
assert payload is not None
finding0 = next(f for f in payload["findings"] if f["id"] == "finding-0")
assert finding0["status"] == "rejected"
assert finding0["reject_reason"] == "not actionable this quarter"
@pytest.mark.asyncio
async def test_reject_is_idempotent(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_brief(db_session)
svc = _svc(db_session)
await svc.reject_finding(_id(task), "finding-0", "reason one")
second = await svc.reject_finding(_id(task), "finding-0", "reason two")
assert second is not None
assert second.status == "already_rejected"
@pytest.mark.asyncio
async def test_cannot_reject_an_approved_finding(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_brief(db_session)
svc = _svc(db_session)
await svc.approve_finding(_id(task), "finding-0", created_by=CEO_UUID)
result = await svc.reject_finding(_id(task), "finding-0", "changed my mind")
assert result is not None
assert result.status == "invalid_state"
@pytest.mark.asyncio
async def test_cannot_approve_a_rejected_finding(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_brief(db_session)
svc = _svc(db_session)
await svc.reject_finding(_id(task), "finding-0", "not now")
result = await svc.approve_finding(_id(task), "finding-0", created_by=CEO_UUID)
assert result is not None
assert result.status == "invalid_state"
@pytest.mark.asyncio
async def test_finding_with_no_status_key_defaults_to_proposed(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A finding authored before this feature shipped carries no status key
at all setdefault treats it as proposed, not a crash."""
await _seed_roboco_project(db_session, monkeypatch)
legacy_finding = _finding(0, status=None)
assert "status" not in legacy_finding
task = await _seed_brief(db_session, findings=[legacy_finding])
result = await _svc(db_session).approve_finding(
_id(task), "finding-0", created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
@pytest.mark.asyncio
async def test_approve_unresolvable_project_is_invalid_state(
db_session: AsyncSession,
) -> None:
"""No RoboCo project seeded (and no monkeypatched slug pointing at one)
the materialize fails cleanly instead of guessing a project."""
task = await _seed_brief(db_session)
result = await _svc(db_session).approve_finding(
_id(task), "finding-0", created_by=CEO_UUID
)
assert result is not None
assert result.status == "invalid_state"
assert "not resolvable" in result.detail
@pytest.mark.asyncio
async def test_unknown_task_returns_none(db_session: AsyncSession) -> None:
result = await _svc(db_session).approve_finding(
uuid4(), "finding-0", created_by=CEO_UUID
)
assert result is None
@pytest.mark.asyncio
async def test_unknown_finding_id_returns_none(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_brief(db_session)
result = await _svc(db_session).approve_finding(
_id(task), "finding-999", created_by=CEO_UUID
)
assert result is None
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
session.add(
BoardProgramCycleTable(
program_key="periscope",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await session.flush()
@pytest.mark.asyncio
async def test_approve_records_learn_decision(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_brief(db_session)
await _seed_cycle_ledger_row(db_session, task)
await _svc(db_session).approve_finding(_id(task), "finding-0", created_by=CEO_UUID)
row = (
await db_session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.program_key == "periscope"
)
)
).scalar_one()
assert row.items_approved == ONE
# The ref is the finding's CLAIM (wrapped as learn_ref's "title" input) —
# a finding has no "title" field of its own.
decision = row.decisions[0]
assert decision["verdict"] == "approved"
assert decision["item_ref"] == "Competitor 0 shipped an autonomous review agent"
@pytest.mark.asyncio
async def test_approve_survives_learn_recording_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_brief(db_session)
await _seed_cycle_ledger_row(db_session, task)
async def _boom(_self: object, *_args: object, **_kwargs: object) -> None:
raise RuntimeError("learn boom")
monkeypatch.setattr(bp_module.BoardProgramEngine, "record_decision", _boom)
result = await _svc(db_session).approve_finding(
_id(task), "finding-0", created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
@@ -25,6 +25,7 @@ from roboco.db.tables import (
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
from roboco.models.base import (
AgentRole,
AgentStatus,
@@ -56,6 +57,7 @@ if TYPE_CHECKING:
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
PO_UUID = _foundation.AGENTS["product-owner"].uuid
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
ONE = 1
TWO = 2
@@ -106,6 +108,7 @@ async def _seed_agents(session: AsyncSession) -> None:
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
(CEO_UUID, "ceo", AgentRole.CEO, None),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
if await session.get(AgentTable, uuid) is None:
session.add(
@@ -184,7 +187,12 @@ def _id(task: TaskTable) -> UUID:
@pytest.mark.asyncio
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
async def test_approve_materializes_main_pm_owned_task(
db_session: AsyncSession,
) -> None:
"""Defect fix: mirrors test_roadmap_service.py's identical assertion
update approval materializes PENDING + assigned_to=main-pm, never an
unowned BACKLOG task (see RoadmapService._materialize's docstring)."""
await _seed_project(db_session, "backend-svc")
task = await _seed_cycle(db_session, project_slug="backend-svc")
result = await _svc(db_session).approve_item(
@@ -196,9 +204,24 @@ async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> No
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.status == TS.BACKLOG
assert materialized.status == TS.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
assert materialized.source == PEST_CONTROL_ITEM_SOURCE
assert materialized.team == Team.BACKEND
# team is forced to Team.MAIN_PM (not the item's own cell) — see
# test_roadmap_service.py's identical assertion for why: every "is this
# a coordination root" consumer keys on team, not assigned_to.
assert materialized.team == Team.MAIN_PM
# main_pm can never own a code task — see test_roadmap_service.py's
# identical assertion for the coercion rationale.
assert materialized.task_type == TT.PLANNING
# The item's own cell survives as a Notes delegation hint instead.
assert "backend cell" in (materialized.description or "")
materialized.branch_name = "feature/main_pm/deadbeef"
hint = _next_hint_pr_fail(materialized)
assert "re-delegate" in hint
assert "do NOT re-submit" in hint
await db_session.refresh(task)
payload = markers.get_pest_hunt(task)
+39 -3
View File
@@ -23,6 +23,7 @@ from roboco.db.tables import (
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
from roboco.models.base import (
AgentRole,
AgentStatus,
@@ -49,6 +50,7 @@ if TYPE_CHECKING:
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
PO_UUID = _foundation.AGENTS["product-owner"].uuid
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
ONE = 1
TWO = 2
@@ -98,6 +100,7 @@ async def _seed_agents(session: AsyncSession) -> None:
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
(CEO_UUID, "ceo", AgentRole.CEO, None),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
if await session.get(AgentTable, uuid) is None:
session.add(
@@ -175,7 +178,20 @@ def _id(task: TaskTable) -> UUID:
@pytest.mark.asyncio
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
async def test_approve_materializes_main_pm_owned_task(
db_session: AsyncSession,
) -> None:
"""Defect fix (#703/#704): approval used to materialize an unowned
BACKLOG task nothing dispatches BACKLOG, and once nudged to PENDING a
cell PM could claim + complete it as a bare root, merging straight to
the project's head rung and bypassing the Main-PM root / root->master PR
/ CEO approval gate. It now materializes PENDING + assigned_to=main-pm
instead team is forced to Team.MAIN_PM too (matching
TaskService.approve_and_start), since every "is this a coordination
root" consumer (pr_fail's next-hint, the PR-gate re-delegate steer,
delegate's wave-chain wiring, the PR labeler) keys on team, not
assigned_to. Leaving team on the item's own cell was itself the defect:
the root looked like a bare cell/dev task to all four."""
await _seed_project(db_session, "backend-svc")
task = await _seed_cycle(db_session, project_slug="backend-svc")
result = await _svc(db_session).approve_item(
@@ -187,9 +203,29 @@ async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> No
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.status == TS.BACKLOG
assert materialized.status == TS.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
assert materialized.source == ROADMAP_ITEM_SOURCE
assert materialized.team == Team.BACKEND
assert materialized.team == Team.MAIN_PM
# main_pm can never own a code task (pm_cannot_own_code) — the intake
# coercion in create_task_from_draft retypes it to planning, the same
# shape a Main-PM coordination root always carries.
assert materialized.task_type == TT.PLANNING
# The item's own cell ("backend") didn't just vanish — it survives as a
# Notes delegation hint in the composed description, which the Main PM's
# spawn briefing (_format_task_briefing_block) renders verbatim.
assert "backend cell" in (materialized.description or "")
# The predicate the four consumers key on: with team=Team.MAIN_PM and a
# branch (simulating a claimed root with its assembled PR), pr_fail's
# next-hint steers the Main PM to re-delegate rather than the nonsensical
# "dev will revise" hint a Main PM (no code-revise verb) can't act on.
materialized.branch_name = "feature/main_pm/deadbeef"
hint = _next_hint_pr_fail(materialized)
assert "re-delegate" in hint
assert "do NOT re-submit" in hint
await db_session.refresh(task)
payload = markers.get_roadmap_cycle(task)
@@ -0,0 +1,406 @@
"""SentinelService coverage: per-item approve materializes a Main-PM-owned
root task (idempotent), reject records a reason (idempotent). Mirrors
test_periscope_service.py exactly the exploration task is ALREADY
COMPLETED (complete-at-propose), so only the item's own status is under
test, plus the docs-area task_type override (mirrors MirrorService).
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
import pytest
import pytest_asyncio
from roboco.config import settings as cfg
from roboco.db.tables import (
AgentTable,
BoardProgramCycleTable,
ProjectTable,
SystemSettingTable,
TaskTable,
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
from roboco.models.base import TaskNature as TN
from roboco.models.base import TaskStatus as TS
from roboco.models.base import TaskType as TT
from roboco.services import board_programs as bp_module
from roboco.services.sentinel_service import SentinelService, get_sentinel_service
from roboco.services.task import SENTINEL_ITEM_SOURCE, SENTINEL_SOURCE
from sqlalchemy import delete, select, update
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
AUDITOR_UUID = _foundation.AGENTS["auditor"].uuid
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
ROBOCO_SLUG = "roboco-standin"
ONE = 1
@pytest_asyncio.fixture(autouse=True)
async def _purge_board_program_pollution(db_session: AsyncSession) -> None:
"""See test_board_program_engine.py's identical fixture."""
await db_session.execute(
delete(SystemSettingTable).where(SystemSettingTable.key.like("board_program.%"))
)
await db_session.execute(delete(BoardProgramCycleTable))
await db_session.execute(
update(TaskTable)
.where(
TaskTable.source == SENTINEL_SOURCE,
TaskTable.status.notin_([TS.COMPLETED, TS.CANCELLED]),
)
.values(status=TS.CANCELLED)
)
await db_session.commit()
def _item(idx: int, *, area: str = "waivers", status: str | None = "proposed") -> dict:
item: dict[str, Any] = {
"id": f"item-{idx}",
"area": area,
"observation": f"Minor findings keep getting waived, item {idx}",
"evidence": f"{idx + 3} waived-minor findings this week",
"suggested_action": f"Convert item {idx} to a Pest Control bug task",
}
if status is not None:
item["status"] = status
item["reject_reason"] = None
item["materialized_task_id"] = None
return item
async def _seed_agents(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(AUDITOR_UUID, "auditor", AgentRole.AUDITOR, Team.BOARD),
(CEO_UUID, "ceo", AgentRole.CEO, None),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _seed_roboco_project(
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> ProjectTable:
await _seed_agents(session)
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=ROBOCO_SLUG,
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
session.add(project)
await session.flush()
monkeypatch.setattr(cfg, "self_heal_project_slug", ROBOCO_SLUG)
return project
async def _seed_report(
session: AsyncSession, *, items: list[dict] | None = None
) -> TaskTable:
await _seed_agents(session)
task = TaskTable(
id=uuid4(),
title="Sentinel drift-watch cycle",
description="Assess org-wide quality drift and file ONE report.",
acceptance_criteria=["propose_quality_report() called once"],
status=TS.COMPLETED,
priority=2,
task_type=TT.ADMINISTRATIVE,
nature=TN.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
created_by=SYSTEM_UUID,
assigned_to=AUDITOR_UUID,
team=Team.BOARD,
source=SENTINEL_SOURCE,
confirmed_by_human=False,
)
session.add(task)
await session.flush()
items = items or [_item(0), _item(1)]
markers.set_quality_report(
task,
{
"headline": "Waived findings climbed sharply this week",
"items": items,
"overall_assessment": "Drift is concentrated, not systemic",
},
)
await session.flush()
return task
def _svc(session: AsyncSession) -> SentinelService:
return get_sentinel_service(session)
def _id(task: TaskTable) -> UUID:
return cast("UUID", task.id)
@pytest.mark.asyncio
async def test_approve_materializes_main_pm_owned_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_report(db_session)
result = await _svc(db_session).approve_item(
_id(task), "item-0", created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
assert result.materialized_task_id is not None
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.status == TS.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
assert materialized.source == SENTINEL_ITEM_SOURCE
# team is forced to Team.MAIN_PM — see test_roadmap_service.py's
# identical assertion for why: every "is this a coordination root"
# consumer keys on team, not assigned_to. A process/quality drift item
# has no natural owning cell (the prior Team.BACKEND was an arbitrary
# placeholder, not a real delegation hint), so there is no cell to
# preserve in Notes here.
assert materialized.team == Team.MAIN_PM
# main_pm can never own a code task (pm_cannot_own_code) — the intake
# coercion in create_task_from_draft retypes it to planning, the same
# shape a Main-PM coordination root always carries.
assert materialized.task_type == TT.PLANNING
materialized.branch_name = "feature/main_pm/deadbeef"
hint = _next_hint_pr_fail(materialized)
assert "re-delegate" in hint
assert "do NOT re-submit" in hint
await db_session.refresh(task)
payload = markers.get_quality_report(task)
assert payload is not None
item0 = next(i for i in payload["items"] if i["id"] == "item-0")
assert item0["status"] == "approved"
assert item0["materialized_task_id"] == result.materialized_task_id
assert task.status == TS.COMPLETED
@pytest.mark.asyncio
async def test_docs_area_materializes_documentation_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_report(db_session, items=[_item(0, area="docs")])
result = await _svc(db_session).approve_item(
_id(task), "item-0", created_by=CEO_UUID
)
assert result is not None
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.task_type == TT.DOCUMENTATION
@pytest.mark.asyncio
async def test_approve_is_idempotent(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_report(db_session)
svc = _svc(db_session)
first = await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
second = await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
assert first is not None
assert second is not None
assert second.status == "already_approved"
assert second.materialized_task_id == first.materialized_task_id
result = await db_session.execute(
select(TaskTable).where(
TaskTable.source == SENTINEL_ITEM_SOURCE,
TaskTable.title.like("Sentinel [waivers]:%"),
)
)
assert len(result.scalars().all()) == ONE
@pytest.mark.asyncio
async def test_reject_records_reason(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_report(db_session)
result = await _svc(db_session).reject_item(
_id(task), "item-0", "already tracked elsewhere"
)
assert result is not None
assert result.status == "rejected"
await db_session.refresh(task)
payload = markers.get_quality_report(task)
assert payload is not None
item0 = next(i for i in payload["items"] if i["id"] == "item-0")
assert item0["status"] == "rejected"
assert item0["reject_reason"] == "already tracked elsewhere"
@pytest.mark.asyncio
async def test_reject_is_idempotent(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_report(db_session)
svc = _svc(db_session)
await svc.reject_item(_id(task), "item-0", "reason one")
second = await svc.reject_item(_id(task), "item-0", "reason two")
assert second is not None
assert second.status == "already_rejected"
@pytest.mark.asyncio
async def test_cannot_reject_an_approved_item(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_report(db_session)
svc = _svc(db_session)
await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
result = await svc.reject_item(_id(task), "item-0", "changed my mind")
assert result is not None
assert result.status == "invalid_state"
@pytest.mark.asyncio
async def test_cannot_approve_a_rejected_item(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_report(db_session)
svc = _svc(db_session)
await svc.reject_item(_id(task), "item-0", "not now")
result = await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
assert result is not None
assert result.status == "invalid_state"
@pytest.mark.asyncio
async def test_item_with_no_status_key_defaults_to_proposed(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
legacy_item = _item(0, status=None)
assert "status" not in legacy_item
task = await _seed_report(db_session, items=[legacy_item])
result = await _svc(db_session).approve_item(
_id(task), "item-0", created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
@pytest.mark.asyncio
async def test_approve_unresolvable_project_is_invalid_state(
db_session: AsyncSession,
) -> None:
task = await _seed_report(db_session)
result = await _svc(db_session).approve_item(
_id(task), "item-0", created_by=CEO_UUID
)
assert result is not None
assert result.status == "invalid_state"
assert "not resolvable" in result.detail
@pytest.mark.asyncio
async def test_unknown_task_returns_none(db_session: AsyncSession) -> None:
result = await _svc(db_session).approve_item(uuid4(), "item-0", created_by=CEO_UUID)
assert result is None
@pytest.mark.asyncio
async def test_unknown_item_id_returns_none(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_report(db_session)
result = await _svc(db_session).approve_item(
_id(task), "item-999", created_by=CEO_UUID
)
assert result is None
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
session.add(
BoardProgramCycleTable(
program_key="sentinel",
exploration_task_id=task.id,
opened_at=datetime.now(UTC),
)
)
await session.flush()
@pytest.mark.asyncio
async def test_approve_records_learn_decision(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_report(db_session)
await _seed_cycle_ledger_row(db_session, task)
await _svc(db_session).approve_item(_id(task), "item-0", created_by=CEO_UUID)
row = (
await db_session.execute(
select(BoardProgramCycleTable).where(
BoardProgramCycleTable.program_key == "sentinel"
)
)
).scalar_one()
assert row.items_approved == ONE
decision = row.decisions[0]
assert decision["verdict"] == "approved"
assert decision["item_ref"] == "Convert item 0 to a Pest Control bug task"
@pytest.mark.asyncio
async def test_approve_survives_learn_recording_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed_roboco_project(db_session, monkeypatch)
task = await _seed_report(db_session)
await _seed_cycle_ledger_row(db_session, task)
async def _boom(_self: object, *_args: object, **_kwargs: object) -> None:
raise RuntimeError("learn boom")
monkeypatch.setattr(bp_module.BoardProgramEngine, "record_decision", _boom)
result = await _svc(db_session).approve_item(
_id(task), "item-0", created_by=CEO_UUID
)
assert result is not None
assert result.status == "approved"
+26 -3
View File
@@ -25,6 +25,7 @@ from roboco.db.tables import (
)
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
from roboco.models.base import (
AgentRole,
AgentStatus,
@@ -56,6 +57,7 @@ if TYPE_CHECKING:
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
PO_UUID = _foundation.AGENTS["product-owner"].uuid
CEO_UUID = _foundation.AGENTS["ceo"].uuid
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
ONE = 1
TWO = 2
@@ -109,6 +111,7 @@ async def _seed_agents(session: AsyncSession) -> None:
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
(CEO_UUID, "ceo", AgentRole.CEO, None),
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
):
if await session.get(AgentTable, uuid) is None:
session.add(
@@ -187,7 +190,12 @@ def _id(task: TaskTable) -> UUID:
@pytest.mark.asyncio
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
async def test_approve_materializes_main_pm_owned_task(
db_session: AsyncSession,
) -> None:
"""Defect fix: mirrors test_roadmap_service.py's identical assertion
update approval materializes PENDING + assigned_to=main-pm, never an
unowned BACKLOG task (see RoadmapService._materialize's docstring)."""
await _seed_project(db_session, "backend-svc")
task = await _seed_cycle(db_session, project_slug="backend-svc")
result = await _svc(db_session).approve_item(
@@ -199,9 +207,24 @@ async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> No
materialized = await db_session.get(TaskTable, result.materialized_task_id)
assert materialized is not None
assert materialized.status == TS.BACKLOG
assert materialized.status == TS.PENDING
assert materialized.assigned_to == MAIN_PM_UUID
assert materialized.parent_task_id is None
assert materialized.source == SPACKLE_ITEM_SOURCE
assert materialized.team == Team.BACKEND
# team is forced to Team.MAIN_PM (not the item's own cell) — see
# test_roadmap_service.py's identical assertion for why: every "is this
# a coordination root" consumer keys on team, not assigned_to.
assert materialized.team == Team.MAIN_PM
# main_pm can never own a code task — see test_roadmap_service.py's
# identical assertion for the coercion rationale.
assert materialized.task_type == TT.PLANNING
# The item's own cell survives as a Notes delegation hint instead.
assert "backend cell" in (materialized.description or "")
materialized.branch_name = "feature/main_pm/deadbeef"
hint = _next_hint_pr_fail(materialized)
assert "re-delegate" in hint
assert "do NOT re-submit" in hint
await db_session.refresh(task)
payload = markers.get_gap_fill(task)