Wave 1: PR-gate turn cut, task search, trace timestamps, Secretary edits + e2e scenarios 2–3 (#295)

* feat(tests): e2e scenario 2 — the PM merge chain through the PR gate

Shared arcs extracted (arcs.py: canonical-company seeding + dev/qa/doc
segments); scenario 2 seeds a root->cell->dev hierarchy mid-flight, rides
the child through the scenario-1 arc into the cell branch (real squash
via the fake GitHub), then submit_up -> claim_gate_review/pr_pass ->
dispatcher re-claim (mirrored) -> PM complete merging cell->root. This is
the exact PM->reviewer->PM turn sequence the wave-1 turn cut shortens —
the BEFORE-net. Learned seams scripted: commit-subject validator (>=20
chars), reviewer learning-note gate, pr_pass clears ownership by design.

* feat(runtime): PR-gate turn cut — assembled parents auto-submit to the reviewer

When every child of an assembled parent is terminal, the closure
dispatcher now runs the real submit_up/submit_root through the internal
API as the owning PM (_try_auto_submit) instead of spawning the PM for
that turn — the submit's substance is deterministic gate code. Any gate
refusal falls back to the classic PM closure spawn; pr_fail routing and
the PM's final merge turn are unchanged; umbrellas never auto-submit.
ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED default-on; task.auto_submitted audit
row per cut. Proven by e2e scenario 2b (real API, real gates, real git)
against scenario 2 as the before-net.

* feat(notes): structured note sections carry a written_at trace stamp

Sections are overwrite-in-place, so without a stamp there was no way to
reconstruct WHEN a dev/qa/doc/reviewer note landed (CEO reMarkable item:
trace TIMESTAMPS). apply_structured_note stamps ISO written_at beside
the model fields; the panel notes tab renders it next to each card
title (pre-stamp rows render nothing). Progress updates, commits, and
journal entries already carried timestamps — this was the one gap.

* feat(tasks): server-side task search — title, details, and id prefix

The task list's search box only matched titles client-side, and the
trimmed summary payload deliberately carries no description — so
keyword/details/id search was impossible in the browser by design.
GET /tasks/summary gains q (ILIKE over title+description, id-prefix
match, composed with team/status and the view-permission scoping);
the panel debounces the box into the summary fetch and drops the
title-only client filter that would have hidden description matches.

* feat(wave-1): trace timestamps, real task search, Secretary task edits

- apply_structured_note stamps written_at per section; the panel notes
  tab shows it (the one trace surface without a timestamp).
- GET /tasks/summary?q= searches title+description+id-prefix server-side
  (summaries carry no description by design); panel debounces into the
  fetch and drops the title-only client filter.
- Secretary control_task gains a CEO-gated edit action over the content
  allowlist, and GET /secretary/tasks?q= resolves task names to ids for
  the chat. PM-side expansion deferred per the CEO's 'not that much'.

* fix(workspace): dep-update probe scrubs the inherited venv pin

Under uv run the orchestrator's process tree carries VIRTUAL_ENV, and a
uv-based dep_update_command in the throwaway probe clone would target
that venv instead of the clone's — the same hazard _uv_subprocess_env
already guards on the install path.

* build: private per-repo uv cache — isolate from machine-wide uvx servers

Root cause of the recurring rich/pip/bandit rot, with evidence: uv cache
clean timed out on the ~/.cache/uv lock ('is another uv process
running?') — three uvx mcp-server-fetch processes (Claude Code fetch MCP,
one alive since Wednesday) share that cache and race repo syncs on it;
poisoned entries then survive venv rebuilds because rm -rf .venv never
touches the cache, and every re-link reproduces the breakage. UV_CACHE_DIR
now pins <repo>/.uv-cache (gitignored). The earlier UV_NO_SYNC
serialization stays as defense-in-depth but was not the whole story.

* feat(tests): e2e scenario 3 — pr_fail revision loop + root→CEO chain

3a: reviewer pr_fail with a concrete issue -> needs_revision ->
i_will_plan re-entry (full plan gates) -> real fix lands on the cell
branch (the unchanged-PR hard gate refuses resubmit until it does) ->
clean second pass -> merge. 3b: submit_root -> gate -> Main PM complete
escalates the root to the CEO -> the REAL approve-and-merge endpoint
squash-merges to the origin's master. Harness gains the tasks router, a
seeded CEO identity, origin_commit, and a fake GitHub whose head.sha is
recomputed live (real-GitHub semantics the unchanged gate reads). Seeds
now encode the real shape: delivery roots are team=main_pm and
planning-typed.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-02 21:05:50 +02:00
committed by GitHub
co-authored by Renn F
parent 6b5691b02a
commit d1cf6ecbf3
27 changed files with 1722 additions and 373 deletions
+3
View File
@@ -110,3 +110,6 @@ docs/internal/
# MkDocs build output (published to gh-pages by CI; never committed to master) # MkDocs build output (published to gh-pages by CI; never committed to master)
/site/ /site/
.playwright-mcp/ .playwright-mcp/
# Private per-repo uv cache (see Makefile UV_CACHE_DIR)
.uv-cache/
+11
View File
@@ -4,6 +4,17 @@ All notable changes to RoboCo are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **The PR-gate turn cut — assembled parents auto-submit to the reviewer.** When every child of an assembled parent is terminal, the orchestrator used to spawn the PM just to call `submit_up`/`submit_root` — a whole agent turn whose substance (freshness rebase, integrity check, PR open) is deterministic gate code. The closure dispatcher now runs the REAL submit verb through the internal API as the owning PM (`_try_auto_submit`); the task lands in `awaiting_pr_review` and the reviewer dispatch takes it with no PM turn spent. Every gate is intact: a submit rejection (freshness/integrity — the case that genuinely needs judgment) falls back to the classic PM closure spawn, `pr_fail` still routes `needs_revision` to the PM, and the PM keeps the final merge turn. Branchless coordination parents (MegaTask umbrellas) never auto-submit. Gated by `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (default **on**); each auto-submit leaves a `task.auto_submitted` audit row.
- **Task search that actually searches.** The task list's search box only matched titles client-side — and the trimmed summary payload deliberately carries no description, so keyword/details/id search was impossible in the browser by design. `GET /tasks/summary` gains `q` (ILIKE over title + description, id-prefix match, composed with team/status filters and view-permission scoping); the panel debounces the box into the fetch and drops the title-only client filter that would have hidden description matches.
- **Trace timestamps on structured notes.** Note sections (dev/qa/doc/reviewer/handoff) are overwrite-in-place with no stamp, so there was no way to reconstruct WHEN a note landed. `apply_structured_note` now stamps ISO `written_at` beside the model fields and the panel notes tab renders it next to each card title. Progress updates, commits, and journal entries already carried timestamps — this closed the one gap.
- **The Secretary can modify tasks (CEO-gated).** The `control_task` directive gains an `edit` action restricted to the content allowlist (title, description, acceptance criteria, priority) — status/ownership/git fields keep their own audited paths — and `GET /secretary/tasks?q=` resolves task NAMES to ids (Secretary/CEO only), so a CEO chat instruction like "sharpen the greeting task's title" can target the right task without a UUID.
- **e2e scenario 3 — the pr_fail revision loop and the root → CEO chain.** 3a walks the honest revision loop: the reviewer `pr_fail`s the assembled cell PR with a concrete issue → `needs_revision` → the PM re-enters via `i_will_plan` (full plan gates), real fix work lands on the cell branch (the 0.14.0 unchanged-PR hard gate correctly refuses resubmission until it does), and the second gate pass rides through to the merge. 3b closes the whole company loop with no LLM anywhere: `submit_root` opens the root→master PR, the reviewer gate-passes it, the Main PM's `complete` escalates the root parent to the CEO, and the REAL `POST /tasks/{id}/approve-and-merge` endpoint squash-merges to the origin's master. The fake GitHub now recomputes `head.sha` live from the branch (real-GitHub semantics the unchanged-PR gate depends on).
- **e2e scenarios 2 + 2b — the PM merge chain, before and after the cut.** Shared scripted-agent arcs (`tests/e2e_smoke/arcs.py`) drive a root→cell→dev hierarchy: the child lands via the scenario-1 arc (real squash through the fake GitHub), then scenario 2 walks the classic PM `submit_up` → reviewer `pr_pass` → dispatcher re-claim → PM merge chain, and scenario 2b proves the turn cut end-to-end — no agent calls submit; `_try_auto_submit` drives the real verb through the real API and the reviewer→PM tail runs unchanged, landing the child's file on the root branch.
## [0.16.0] - 2026-07-02 ## [0.16.0] - 2026-07-02
### Added ### Added
+8
View File
@@ -10,6 +10,14 @@ DEFAULT_PYTHON = 3.10
# fresh env depend on the explicit `sync` below, which runs once, up front. # fresh env depend on the explicit `sync` below, which runs once, up front.
export UV_NO_SYNC := 1 export UV_NO_SYNC := 1
# Private per-repo uv cache. The user-level ~/.cache/uv is SHARED with every
# `uvx` tool server on the machine (e.g. Claude Code's mcp-server-fetch runs
# for days holding/contending the cache lock); concurrent cache writes from
# those processes poisoned package entries (rich/pip/bandit rot that survived
# venv rebuilds — the cache, not the venv, was the carrier). One-time cost:
# the first sync re-downloads; after that, total isolation.
export UV_CACHE_DIR := $(CURDIR)/.uv-cache
.PHONY: sync .PHONY: sync
sync: sync:
@echo "==> uv sync --extra dev" @echo "==> uv sync --extra dev"
+17
View File
@@ -578,3 +578,20 @@ Slices touched: orchestrator (1, 7), api-routes-schemas + taskservice (2), panel
5. **e2e smoke harness**`tests/e2e_smoke/{conftest,harness,test_dev_lifecycle}.py` + `make e2e-smoke` (env-gated `ROBOCO_E2E_SMOKE=1`, skipped in the default suite). Harness: real routers/middleware on uvicorn over the ephemeral test DB (`settings.database_*` patched + `_DbHolder` reset), bare origin at `<tmp>/github.com/e2e-smoke/proj.git` (satisfies `_parse_git_url`, clones tokenless), fake GitHub REST router doing real squash-merges via an admin clone, `ScriptedAgent` reloading the real MCP modules per role. Scenario 1 (leaf dev arc → awaiting_pm_review) GREEN in ~5s. Learned seams scripted: post-claim tracing gap keeps the claim; note scopes are decision/learning/note/reflect/struggle (no 'progress'); i_am_done demands during-work+handoff+reflect+per-AC artifacts; pass_review demands learning note + ac_verdicts; A2A resolves roles from the STATIC agents_config registry (seed canonical slugs: be-dev-1/be-qa/be-doc/be-pm/main-pm). 5. **e2e smoke harness**`tests/e2e_smoke/{conftest,harness,test_dev_lifecycle}.py` + `make e2e-smoke` (env-gated `ROBOCO_E2E_SMOKE=1`, skipped in the default suite). Harness: real routers/middleware on uvicorn over the ephemeral test DB (`settings.database_*` patched + `_DbHolder` reset), bare origin at `<tmp>/github.com/e2e-smoke/proj.git` (satisfies `_parse_git_url`, clones tokenless), fake GitHub REST router doing real squash-merges via an admin clone, `ScriptedAgent` reloading the real MCP modules per role. Scenario 1 (leaf dev arc → awaiting_pm_review) GREEN in ~5s. Learned seams scripted: post-claim tracing gap keeps the claim; note scopes are decision/learning/note/reflect/struggle (no 'progress'); i_am_done demands during-work+handoff+reflect+per-AC artifacts; pass_review demands learning note + ac_verdicts; A2A resolves roles from the STATIC agents_config registry (seed canonical slugs: be-dev-1/be-qa/be-doc/be-pm/main-pm).
Slices touched: worksession-git (3), orchestrator (1), deployment-tooling (2, 4), tests (5). Slices touched: worksession-git (3), orchestrator (1), deployment-tooling (2, 4), tests (5).
---
## Delta 2026-07-02 (night) — wave 1 begins (branch `feat/wave-1`, post-v0.16.0)
1. **PR-gate turn cut**`orchestrator._try_auto_submit` (+ `_AUTO_SUBMIT_VERB_BY_ROLE`), hooked in `_maybe_spawn_pm_closure` after the all-descendants-terminal check: POSTs the real `submit_up`/`submit_root` through the internal API as the owning PM (X-Agent-ID = task.assigned_to, fallback static `AGENT_UUIDS`); any refusal falls back to the classic PM closure spawn. Flag `ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED` (config, default on); audit `task.auto_submitted`. Tests: `tests/unit/runtime/test_pr_gate_auto_submit.py` (7) + e2e scenario 2b.
2. **e2e scenarios 2/2b**`tests/e2e_smoke/arcs.py` (canonical-company seeding — A2A resolves roles from static agents_config so slugs must match; `seed_hierarchy` with task-short-id branch chains; dev/qa/doc/reviewer arcs; `dispatcher_assign` mirroring `_dispatch_pm_review_work`'s claim-for-PM lane since `pr_pass` clears ownership BY DESIGN) + `test_pm_merge_chain.py`. Seams scripted: commit-subject validator ≥20 chars; reviewer learning-note gate before pr_pass; child PR bases on the parent's branch via ancestor resolution.
3. **Trace timestamps**`content_notes.apply_structured_note` stamps `written_at` into each stored section; panel `tab-notes.tsx` renders it (`FIELD_TO_SECTION` mirror map).
4. **Task search**`TaskService.search_tasks` (ILIKE title/description + id-prefix) behind `GET /tasks/summary?q=`; panel debounces into the fetch, client title-filter removed. PLR0913 added to the routes per-file ignore (route signatures ARE the HTTP contract).
5. **Secretary task writes**`control_task` action `edit` (allowlist title/description/acceptance_criteria/priority, `_EDITABLE_TASK_FIELDS`) + `GET /secretary/tasks?q=` name→id resolver (Secretary/CEO). PM-side expansion deliberately deferred (CEO: "PMs not that much").
Slices touched: orchestrator (1), tests (1, 2), taskservice + api-routes-schemas (3, 4, 5), panel (3, 4), secretary (5).
---
## Delta 2026-07-02 (late night) — e2e scenario 3 (branch `feat/wave-1`)
`tests/e2e_smoke/test_root_ceo_chain.py`: 3a pr_fail→needs_revision→`i_will_plan` re-entry (route demands approach≥150 + sub_tasks even on re-claim — pydantic fires before the gateway short-circuit)→real fix commit (`origin_commit` helper)→resubmit→pass→merge; 3b submit_root→gate→complete-escalates→REAL `approve-and-merge` (tasks router now mounted in the harness app; CEO row seeded)→hello.txt on origin master. Seed corrections that ARE the documentation: delivery roots are team=main_pm + planning-typed (backend-team roots get closure-routed to the cell PM; code-typed roots hit the main_pm+code impossibility guard). Fake GitHub `get_pr` now recomputes head.sha live (the unchanged-PR gate reads it via the REST API, not local refs). Latent fix en route: dep-update probe env scrub (VIRTUAL_ENV). uv-rot root cause: shared ~/.cache/uv with long-lived uvx MCP servers — per-repo UV_CACHE_DIR pinned.
+12 -11
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Suspense, useMemo, useCallback } from "react"; import { Suspense, useEffect, useMemo, useCallback, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useTasks } from "@/hooks/use-tasks"; import { useTasks } from "@/hooks/use-tasks";
import { useProjects } from "@/hooks/use-projects"; import { useProjects } from "@/hooks/use-projects";
@@ -160,7 +160,17 @@ function TasksPageContent() {
); );
// Fetch all tasks and filter client-side for multi-select // Fetch all tasks and filter client-side for multi-select
const { data: tasks, isLoading, error, refetch } = useTasks(); // Debounced server-side search: title + description + id prefix. The
// old client-side title-only filter hid description/id matches the
// server now returns, so it is gone.
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
useEffect(() => {
const handle = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(handle);
}, [searchQuery]);
const { data: tasks, isLoading, error, refetch } = useTasks(
debouncedQuery ? { q: debouncedQuery } : undefined,
);
// Projects + products: power the Project/Product filter options + name display. // Projects + products: power the Project/Product filter options + name display.
const { data: projects } = useProjects(); const { data: projects } = useProjects();
@@ -191,14 +201,6 @@ function TasksPageContent() {
if (!tasks) return []; if (!tasks) return [];
return tasks.filter((task) => { return tasks.filter((task) => {
// Search filter
if (
searchQuery &&
!task.title.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Status filter (if any selected, task must match one of them) // Status filter (if any selected, task must match one of them)
if (statusFilter.length > 0 && !statusFilter.includes(task.status)) { if (statusFilter.length > 0 && !statusFilter.includes(task.status)) {
return false; return false;
@@ -239,7 +241,6 @@ function TasksPageContent() {
}); });
}, [ }, [
tasks, tasks,
searchQuery,
statusFilter, statusFilter,
teamFilter, teamFilter,
taskTypeFilter, taskTypeFilter,
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import React from "react";
import { TaskStatus, Team, TaskType, type Task } from "@/types";
// Structured note sections are overwrite-in-place; apply_structured_note
// stamps written_at so the panel can show WHEN a trace landed (CEO
// reMarkable item: "Task notes and other traces: TIMESTAMPS!").
vi.mock("@/hooks/use-tasks", () => ({
useUpdateTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { TabNotes } from "../tab-notes";
function buildTask(overrides: Partial<Task> = {}): Task {
return {
id: "t1",
title: "Task",
description: "d",
status: TaskStatus.IN_PROGRESS,
team: Team.BACKEND,
task_type: TaskType.CODE,
acceptance_criteria: [],
...overrides,
} as unknown as Task;
}
describe("notes tab written_at stamps", () => {
it("shows the section's written_at next to the card title", () => {
const task = buildTask({
dev_notes: "Built the greeting module.",
notes_structured: {
developer: {
summary: "Built the greeting module.",
written_at: "2026-07-02T18:30:00+00:00",
},
},
} as Partial<Task>);
const { getByTestId } = render(<TabNotes task={task} />);
expect(getByTestId("written-at-dev_notes").textContent).toContain("Jul");
});
it("renders no stamp when the section has none (pre-stamp rows)", () => {
const task = buildTask({
dev_notes: "Legacy note without a structured stamp.",
notes_structured: { developer: { summary: "Legacy note." } },
} as Partial<Task>);
const { queryByTestId } = render(<TabNotes task={task} />);
expect(queryByTestId("written-at-dev_notes")).toBeNull();
});
});
@@ -86,6 +86,47 @@ function prReviewCardBg(task: Task): string {
); );
} }
// Mirror-column -> structured-section key (the write-time source of truth).
const FIELD_TO_SECTION: Record<NoteField, string> = {
quick_context: "resumption",
dev_notes: "developer",
qa_notes: "qa",
auditor_notes: "auditor",
pr_reviewer_notes: "pr_review",
doc_notes: "doc",
};
// When the section was last written (apply_structured_note stamps it).
function writtenAt(task: Task, field: NoteField): string | null {
const sections = task.notes_structured as
| Record<string, { written_at?: string }>
| null
| undefined;
const stamp = sections?.[FIELD_TO_SECTION[field]]?.written_at;
if (!stamp) return null;
const date = new Date(stamp);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function WrittenAtStamp({ task, field }: { task: Task; field: NoteField }) {
const stamp = writtenAt(task, field);
if (!stamp) return null;
return (
<span
className="text-xs font-normal text-muted-foreground ml-2"
data-testid={`written-at-${field}`}
>
{stamp}
</span>
);
}
interface NoteCardProps { interface NoteCardProps {
task: Task; task: Task;
field: NoteField; field: NoteField;
@@ -193,6 +234,7 @@ function EditableNoteCard({
{icon} {icon}
{title} {title}
{badge} {badge}
<WrittenAtStamp task={task} field={field} />
</CardTitle> </CardTitle>
{isEditing ? ( {isEditing ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+4
View File
@@ -19,6 +19,9 @@ export interface TaskFilters {
team?: Team; team?: Team;
limit?: number; limit?: number;
offset?: number; offset?: number;
// Server-side search over title, description, and id prefix — summaries
// carry no description, so the search must happen on the backend.
q?: string;
} }
// One board reviewer's decision-log entry for a task (PO or Head of Marketing). // One board reviewer's decision-log entry for a task (PO or Head of Marketing).
@@ -107,6 +110,7 @@ export const tasksApi = {
if (filters?.status) params.append("status", filters.status); if (filters?.status) params.append("status", filters.status);
if (filters?.team) params.append("team", filters.team); if (filters?.team) params.append("team", filters.team);
if (filters?.limit) params.append("limit", String(filters.limit)); if (filters?.limit) params.append("limit", String(filters.limit));
if (filters?.q) params.append("q", filters.q);
const url = "/tasks/summary?" + params.toString(); const url = "/tasks/summary?" + params.toString();
const { data } = await api.get<TaskSummaryWire[]>(url); const { data } = await api.get<TaskSummaryWire[]>(url);
+4 -1
View File
@@ -160,7 +160,10 @@ select = [
# tool layer; we accept the >5 kwarg signatures here for the same reason # tool layer; we accept the >5 kwarg signatures here for the same reason
# they're accepted in `roboco/mcp/**`. # they're accepted in `roboco/mcp/**`.
"roboco/services/gateway/**/*.py" = ["PLC0415", "PLR0913"] "roboco/services/gateway/**/*.py" = ["PLC0415", "PLR0913"]
"roboco/api/routes/*.py" = ["PLC0415"] # Route signatures ARE the HTTP contract — each FastAPI query/path/body
# param must be a discrete typed argument for OpenAPI + validation, so the
# >5-arg rule doesn't fit them (same rationale as roboco/mcp/**).
"roboco/api/routes/*.py" = ["PLC0415", "PLR0913"]
# deps.py is the DI wiring hub; it defers a few service imports to call time # deps.py is the DI wiring hub; it defers a few service imports to call time
# to avoid import cycles with the modules it wires (same rationale as above). # to avoid import cycles with the modules it wires (same rationale as above).
"roboco/api/deps.py" = ["PLC0415"] "roboco/api/deps.py" = ["PLC0415"]
+30 -1
View File
@@ -5,9 +5,10 @@ queued and the CEO confirms/rejects them (CEO-only routes). The Secretary also
reads company state. Writes commit explicitly. reads company state. Writes commit explicitly.
""" """
from typing import Annotated
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, Query, status
from roboco.api.deps import CurrentAgentContext, DbSession from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.secretary import ( from roboco.api.schemas.secretary import (
@@ -43,6 +44,34 @@ async def read_state(db: DbSession, agent: CurrentAgentContext) -> CompanyStateR
return CompanyStateResponse(**state) return CompanyStateResponse(**state)
@router.get("/tasks")
async def search_tasks(
db: DbSession,
agent: CurrentAgentContext,
q: Annotated[str, Query(min_length=2, max_length=200)],
limit: Annotated[int, Query(ge=1, le=50)] = 20,
) -> list[dict[str, object]]:
"""Search tasks by title/description/id prefix (Secretary or CEO).
The CEO refers to tasks by NAME in the Secretary chat; this resolves a
name to concrete ids so a directive can target the right task.
"""
_require(agent, _SECRETARY_OR_CEO)
from roboco.services.task import get_task_service
rows = await get_task_service(db).search_tasks(q, limit=limit)
return [
{
"id": str(row.id),
"title": row.title,
"status": str(row.status),
"team": str(row.team) if row.team else None,
"priority": row.priority,
}
for row in rows
]
@router.get("/tasks/{task_id}") @router.get("/tasks/{task_id}")
async def read_task( async def read_task(
task_id: UUID, db: DbSession, agent: CurrentAgentContext task_id: UUID, db: DbSession, agent: CurrentAgentContext
+10 -1
View File
@@ -662,6 +662,7 @@ async def list_tasks_summary(
agent: CurrentAgentContext, agent: CurrentAgentContext,
team: Team | None = None, team: Team | None = None,
status: TaskStatus | None = None, status: TaskStatus | None = None,
q: Annotated[str | None, Query(max_length=200)] = None,
limit: Annotated[int, Query(ge=1, le=1000)] = 500, limit: Annotated[int, Query(ge=1, le=1000)] = 500,
) -> list[TaskSummaryResponse]: ) -> list[TaskSummaryResponse]:
"""List tasks as trimmed summaries for panel list views. """List tasks as trimmed summaries for panel list views.
@@ -669,7 +670,9 @@ async def list_tasks_summary(
Same filters and view permissions as the full list, ~50x lighter per Same filters and view permissions as the full list, ~50x lighter per
task: no description/plan/progress/commits/notes. The panel task tree task: no description/plan/progress/commits/notes. The panel task tree
needs the whole set at once, so the default limit is higher than the needs the whole set at once, so the default limit is higher than the
full route's. full route's. ``q`` searches title, description, and id prefix
server-side summaries carry no description, so the search must
happen here, not in the browser.
""" """
service = get_task_service(db) service = get_task_service(db)
permissions = get_permission_service() permissions = get_permission_service()
@@ -681,6 +684,12 @@ async def list_tasks_summary(
else: else:
return [] return []
if q:
tasks = await service.search_tasks(
q, team=effective_team, status=status, limit=limit
)
return task_list_to_summary_response(tasks)
if effective_team and status: if effective_team and status:
tasks = await service.list_by_team(effective_team, status, limit) tasks = await service.list_by_team(effective_team, status, limit)
elif effective_team: elif effective_team:
+12
View File
@@ -230,6 +230,18 @@ class Settings(BaseSettings):
"Off => legacy behavior (respawn until the strike breaker trips)." "Off => legacy behavior (respawn until the strike breaker trips)."
), ),
) )
pr_gate_auto_submit_enabled: bool = Field(
default=True,
description=(
"When every child of an assembled parent is terminal, run the "
"submit_up/submit_root gate system-side as the owning PM instead "
"of spawning the PM for that turn — the submit's substance "
"(freshness rebase, integrity check, PR open) is deterministic "
"gate code. A gate rejection falls back to the classic PM "
"closure spawn; the PM keeps the judgment turns (merge, "
"revision). Off => every closure spawns the PM to submit."
),
)
gateway_health_enabled: bool = Field( gateway_health_enabled: bool = Field(
default=True, default=True,
description=( description=(
+122 -8
View File
@@ -10246,6 +10246,125 @@ Start now: evidence(task_id="{task_id}")
return self._TEAM_PM_MAP.get(team, "be-pm") return self._TEAM_PM_MAP.get(team, "be-pm")
return "main-pm" return "main-pm"
# Which flow route + verb submits an assembled parent, per PM role.
_AUTO_SUBMIT_VERB_BY_ROLE: ClassVar[dict[str, tuple[str, str]]] = {
"cell_pm": ("cell_pm", "submit_up"),
"main_pm": ("main_pm", "submit_root"),
}
def _auto_submit_target(
self, task: dict[str, Any], pm_slug: str
) -> tuple[str, str, str, str] | None:
"""(role, route, verb, pm_uuid) when this parent is auto-submittable.
None when the flag is off, the parent is branchless coordination (a
MegaTask umbrella assembles no PR), the role has no submit verb, or
no PM identity can be resolved.
"""
role = get_agent_role(pm_slug) or ""
pair = self._AUTO_SUBMIT_VERB_BY_ROLE.get(role)
pm_uuid = str(task.get("assigned_to") or AGENT_UUIDS.get(pm_slug) or "")
if (
not settings.pr_gate_auto_submit_enabled
or not task.get("branch_name")
or not task.get("project_id")
or pair is None
or not pm_uuid
):
return None
return (role, pair[0], pair[1], pm_uuid)
async def _try_auto_submit(
self, client: httpx.AsyncClient, task: dict[str, Any], pm_slug: str
) -> bool:
"""Submit an assembled, all-children-terminal parent to the PR gate
WITHOUT spawning its PM the turn's substance (freshness rebase,
integrity check, PR open) is deterministic gate code, so the real
submit verb is run through the internal API as the owning PM.
Returns True when the gate accepted (the reviewer dispatch takes it
from awaiting_pr_review); False on ANY refusal flag off, a
branchless coordination parent (a MegaTask umbrella assembles no
PR), an unmapped role, a gate rejection (freshness/integrity the
PM turn is then genuinely needed), or a transport error and the
caller falls back to the classic PM closure spawn.
"""
target = self._auto_submit_target(task, pm_slug)
if target is None:
return False
role, role_path, verb, pm_uuid = target
task_id = str(task.get("id"))
notes = (
"Auto-submitted for gate review: every child task is terminal and "
"the assembled branch is ready. Freshness and integrity are "
"enforced by the submit gate itself; the in-path PR reviewer "
"takes it from here."
)
try:
resp = await client.post(
f"{self._api_url}/v1/flow/{role_path}/{verb}",
headers={"X-Agent-ID": pm_uuid, "X-Agent-Role": role},
json={"task_id": task_id, "notes": notes},
)
body = resp.json()
except Exception as e:
logger.warning(
"Auto-submit transport failure; falling back to PM closure spawn",
task_id=task_id,
error=str(e),
)
return False
if not isinstance(body, dict) or body.get("error"):
logger.info(
"Auto-submit rejected by the gate; PM closure spawn proceeds",
task_id=task_id,
error=(body or {}).get("error") if isinstance(body, dict) else body,
message=(body or {}).get("message") if isinstance(body, dict) else None,
)
return False
logger.info(
"Assembled parent auto-submitted to the PR gate (PM turn skipped)",
task_id=task_id,
verb=verb,
pm=pm_slug,
)
self._fire_audit(
event_type="task.auto_submitted",
agent_slug=pm_slug,
task_id=task_id,
details={"verb": verb, "auto": True},
)
self._mark_task_handled(task_id)
return True
async def _closure_handled_without_pm(
self,
client: httpx.AsyncClient,
task: dict[str, Any],
task_id: str,
pm_id: str,
) -> bool:
"""Recover the parent's status, then try the submit turn cut.
The parent auto-paused when its PM idled (by design) resume it
before anything else so whoever acts next (the auto-submit or the
spawned PM) lands on an actionable in_progress parent; an errant
`blocked` at closure is recovered symmetrically. Then the turn cut:
an assembled parent whose children are all terminal is submitted to
the PR gate system-side (True => the PM spawn is skipped); parents
past the gate (awaiting_pm_review the merge turn) always spawn.
"""
parent_status = task.get("status")
if parent_status == "paused":
await self._auto_resume_paused_parent(client, task_id)
elif parent_status == "blocked":
await self._auto_recover_blocked_parent(client, task_id)
return parent_status in (
"claimed",
"in_progress",
"paused",
) and await self._try_auto_submit(client, task, pm_id)
async def _maybe_spawn_pm_closure( async def _maybe_spawn_pm_closure(
self, client: httpx.AsyncClient, task: dict[str, Any] self, client: httpx.AsyncClient, task: dict[str, Any]
) -> None: ) -> None:
@@ -10263,9 +10382,7 @@ Start now: evidence(task_id="{task_id}")
return return
descendants = await self._fetch_all_descendants(client, task_id) descendants = await self._fetch_all_descendants(client, task_id)
if not descendants: if not descendants or not self._all_descendants_terminal(descendants):
return
if not self._all_descendants_terminal(descendants):
return return
if self._already_promoted_for_closure(task): if self._already_promoted_for_closure(task):
return return
@@ -10289,11 +10406,8 @@ Start now: evidence(task_id="{task_id}")
# A parent that is `blocked` at closure (all descendants # A parent that is `blocked` at closure (all descendants
# terminal) is an errant/stale block — recover it symmetrically so # terminal) is an errant/stale block — recover it symmetrically so
# the chain can't wedge forever waiting for a PM to manually unblock. # the chain can't wedge forever waiting for a PM to manually unblock.
parent_status = task.get("status") if await self._closure_handled_without_pm(client, task, task_id, pm_id):
if parent_status == "paused": return
await self._auto_resume_paused_parent(client, task_id)
elif parent_status == "blocked":
await self._auto_recover_blocked_parent(client, task_id)
prompt = self._build_pm_closure_prompt(task, descendants) prompt = self._build_pm_closure_prompt(task, descendants)
await self.spawn_agent( await self.spawn_agent(
+7 -1
View File
@@ -13,6 +13,7 @@ they are derived, never authored directly.
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from typing import Any, Protocol from typing import Any, Protocol
from roboco.foundation.policy.content import ContentModel, validate_content from roboco.foundation.policy.content import ContentModel, validate_content
@@ -65,7 +66,12 @@ def apply_structured_note(
model = validate_content(content_type, payload) model = validate_content(content_type, payload)
structured = dict(task.notes_structured or {}) structured = dict(task.notes_structured or {})
structured[content_type] = model.model_dump(mode="json") section = model.model_dump(mode="json")
# Trace timestamp: sections are overwrite-in-place, so without a stamp
# there is no way to reconstruct WHEN a note landed. Stored beside the
# model fields (not on the model — schemas stay author-facing).
section["written_at"] = datetime.now(UTC).isoformat()
structured[content_type] = section
task.notes_structured = structured # reassign so the JSON column flags dirty task.notes_structured = structured # reassign so the JSON column flags dirty
column = _MIRROR_COLUMN.get(content_type) column = _MIRROR_COLUMN.get(content_type)
+19 -1
View File
@@ -14,7 +14,7 @@ holds CEO authority itself; this service mediates it.
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, ClassVar
from sqlalchemy import select from sqlalchemy import select
@@ -226,11 +226,29 @@ class SecretaryService(BaseService):
return "pitch approved and provisioned" return "pitch approved and provisioned"
return await self._control_task(payload) return await self._control_task(payload)
# Content fields the Secretary may edit on CEO confirmation. Status,
# ownership, and git fields never ride an edit — they have their own
# audited paths (override, reassign, the git workflow).
_EDITABLE_TASK_FIELDS: ClassVar[frozenset[str]] = frozenset(
{"title", "description", "acceptance_criteria", "priority"}
)
async def _control_task(self, payload: dict[str, Any]) -> str: async def _control_task(self, payload: dict[str, Any]) -> str:
task_svc = get_task_service(self.session) task_svc = get_task_service(self.session)
task_id = require_uuid(payload["task_id"]) task_id = require_uuid(payload["task_id"])
action = str(payload["action"]) action = str(payload["action"])
notes = str(payload.get("notes", "via Secretary on CEO command")) notes = str(payload.get("notes", "via Secretary on CEO command"))
if action == "edit":
fields = dict(payload.get("fields") or {})
illegal = set(fields) - self._EDITABLE_TASK_FIELDS
if not fields or illegal:
raise ValidationError(
"edit accepts only "
f"{sorted(self._EDITABLE_TASK_FIELDS)}; got "
f"{sorted(fields) or 'nothing'}"
)
await task_svc.update(task_id, **fields)
return f"task fields updated: {', '.join(sorted(fields))}"
if action == "start": if action == "start":
await task_svc.approve_and_start(task_id, notes) await task_svc.approve_and_start(task_id, notes)
return "task started" return "task started"
+31 -1
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, cast from typing import TYPE_CHECKING, Any, ClassVar, cast
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import and_, func, or_, select, text, update from sqlalchemy import String, and_, func, or_, select, text, update
from sqlalchemy import inspect as sa_inspect from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import InstanceState from sqlalchemy.orm import InstanceState
@@ -6304,6 +6304,36 @@ class TaskService(BaseService):
# QUERIES # QUERIES
# ========================================================================= # =========================================================================
async def search_tasks(
self,
q: str,
*,
team: Team | None = None,
status: TaskStatus | None = None,
limit: int = 100,
) -> list[TaskTable]:
"""Case-insensitive task search over title, description, and id.
Backs the panel's task-list search bar: title/keyword/details hits
via ILIKE, plus an id-prefix match so a pasted short id resolves.
Filters compose with the list routes' team/status semantics.
"""
needle = f"%{q}%"
conditions = [
TaskTable.title.ilike(needle),
TaskTable.description.ilike(needle),
cast("Any", TaskTable.id).cast(String).ilike(f"{q}%"),
]
stmt = select(TaskTable).where(or_(*conditions))
if team is not None:
stmt = stmt.where(TaskTable.team == team)
if status is not None:
stmt = stmt.where(TaskTable.status == status)
result = await self.session.execute(
stmt.order_by(TaskTable.created_at.desc()).limit(limit)
)
return list(result.scalars().all())
async def list_all( async def list_all(
self, self,
limit: int = 100, limit: int = 100,
+8
View File
@@ -1724,9 +1724,17 @@ class WorkspaceService:
never touches the read clone. never touches the read clone.
""" """
timeout = settings.workspace_dep_install_timeout_seconds timeout = settings.workspace_dep_install_timeout_seconds
# Scrub the inherited venv pin: under `uv run` the orchestrator's
# process tree carries VIRTUAL_ENV=<its own venv>, and a uv-based
# dep_update_command would target THAT venv instead of the throwaway
# clone's. Same hazard _uv_subprocess_env guards for installs.
probe_env = dict(os.environ)
probe_env.pop("VIRTUAL_ENV", None)
probe_env.pop("UV_PROJECT_ENVIRONMENT", None)
upgrade = subprocess.run( upgrade = subprocess.run(
shlex.split(command), shlex.split(command),
cwd=str(clone_dir), cwd=str(clone_dir),
env=probe_env,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=timeout, timeout=timeout,
+572
View File
@@ -0,0 +1,572 @@
"""Reusable scripted-agent arcs + seeding for the e2e smoke scenarios.
The company is seeded ONCE per stack session (canonical slugs the A2A
permission model resolves roles/teams from the static ``agents_config``
registry, so slugs must match it). Projects and tasks are seeded per test
with unique slugs so scenarios never collide on constraints.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from tests.e2e_smoke.harness import E2EStack, ScriptedAgent, expect_error, expect_ok
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
class Company:
"""Seeded canonical agents (ids) — one per stack session."""
dev_id: Any
qa_id: Any
doc_id: Any
cell_pm_id: Any
main_pm_id: Any
pr_reviewer_id: Any
ceo_id: Any
_COMPANY_CACHE: dict[str, Company] = {}
def seed_company(stack: E2EStack) -> Company:
"""Seed the canonical agents once; return their ids on every call."""
if "company" in _COMPANY_CACHE:
return _COMPANY_CACHE["company"]
from roboco.db.tables import AgentTable
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:
row = AgentTable(
id=uuid4(),
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt=slug,
capabilities=[],
permissions={},
metrics={},
)
session.add(row)
return row
dev = agent("be-dev-1", AgentRole.DEVELOPER, Team.BACKEND)
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)
reviewer = agent("pr-reviewer-1", AgentRole.PR_REVIEWER, None)
ceo = agent("ceo", AgentRole.CEO, None)
await session.flush()
out.ceo_id = ceo.id
out.dev_id = dev.id
out.qa_id = qa.id
out.doc_id = doc.id
out.cell_pm_id = cell_pm.id
out.main_pm_id = main_pm.id
out.pr_reviewer_id = reviewer.id
stack.run_db(_run)
_COMPANY_CACHE["company"] = out
return out
def seed_project(stack: E2EStack, company: Company) -> tuple[Any, str]:
"""Seed a project rooted at the shared bare origin; unique slug per test."""
from roboco.db.tables import ProjectTable
from roboco.models import Team
from roboco.utils.crypto import encrypt_token
slug = f"e2e-proj-{uuid4().hex[:6]}"
holder: dict[str, Any] = {}
async def _run(session: AsyncSession) -> None:
project = ProjectTable(
id=uuid4(),
name=f"E2E {slug}",
slug=slug,
git_url=str(stack.origin),
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=company.main_pm_id,
is_active=True,
git_token_encrypted=encrypt_token("e2e-dummy-token"),
)
session.add(project)
await session.flush()
holder["id"] = project.id
stack.run_db(_run)
return holder["id"], slug
def seed_task(stack: E2EStack, **overrides: Any) -> Any:
"""Seed one task row; caller passes the fields that matter."""
from roboco.db.tables import TaskTable
from roboco.models import Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
fields: dict[str, Any] = {
"id": uuid4(),
"acceptance_criteria": ["done"],
"status": TaskStatus.PENDING,
"priority": 2,
"task_type": TaskType.CODE,
"nature": TaskNature.TECHNICAL,
"estimated_complexity": Complexity.LOW,
"team": Team.BACKEND,
"confirmed_by_human": True,
}
fields.update(overrides)
async def _run(session: AsyncSession) -> None:
session.add(TaskTable(**fields))
stack.run_db(_run)
return fields["id"]
def task_state(stack: E2EStack, task_id: Any) -> dict[str, Any]:
from roboco.db.tables import TaskTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> dict[str, Any]:
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
return {
"status": str(row.status),
"branch_name": row.branch_name,
"pr_number": row.pr_number,
"docs_complete": row.docs_complete,
"assigned_to": row.assigned_to,
}
state: dict[str, Any] = stack.run_db(_run)
return state
def dispatcher_assign(stack: E2EStack, task_id: Any, agent_id: Any) -> None:
"""Mirror the dispatcher's claim-for-PM lane (_dispatch_pm_review_work):
pr_pass clears ownership by design and the orchestrator re-claims the
task for the owning PM before spawning it."""
from roboco.db.tables import TaskTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> None:
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
row.assigned_to = agent_id
row.active_claimant_id = agent_id
stack.run_db(_run)
def origin_branch(stack: E2EStack, name: str, start: str = "master") -> None:
"""Create + push a branch in the shared origin via the admin clone."""
from tests.e2e_smoke.harness import _git
admin = stack.github.admin_clone
_git(admin, "fetch", "origin", "--prune")
_git(admin, "checkout", "-B", name, f"origin/{start}")
_git(admin, "push", "origin", name)
def origin_commit(
stack: E2EStack, branch: str, path: str, content: str, message: str
) -> None:
"""Land a commit on a branch in the origin via the admin clone —
stands in for dev work advancing a branch between scripted turns."""
from tests.e2e_smoke.harness import _git
admin = stack.github.admin_clone
_git(admin, "fetch", "origin", "--prune")
_git(admin, "checkout", "-B", branch, f"origin/{branch}")
(admin / path).write_text(content)
_git(admin, "add", path)
_git(admin, "commit", "-m", message)
_git(admin, "push", "origin", branch)
def origin_file(stack: E2EStack, branch: str, path: str) -> str | None:
"""Read a file's content at a branch tip in the origin, or None."""
import subprocess
from tests.e2e_smoke.harness import _git
try:
return _git(stack.github.origin, "show", f"{branch}:{path}")
except subprocess.CalledProcessError:
return None
# ---------------------------------------------------------------------------
# Arcs — each drives one role through one lifecycle segment, gates and all
# ---------------------------------------------------------------------------
def dev_arc(
stack: E2EStack,
company: Company,
project_slug: str,
task_id: Any,
*,
work: tuple[str, str] = ("greeting.txt", "Hello from the e2e smoke agent!\n"),
) -> None:
"""PENDING (pre-assigned) → awaiting_qa: claim, work, commit, PR, submit."""
filename, content = work
tid = str(task_id)
dev = ScriptedAgent(stack, company.dev_id, "be-dev-1", "developer")
env = expect_ok(dev.flow("give_me_work"), "dev give_me_work")
assert env.get("task_id") == tid, f"expected task {tid}, got: {env}"
def _claim() -> dict[str, Any]:
return dev.flow(
"i_will_work_on",
task_id=tid,
plan=(
f"Create {filename} at the repository root with the required "
"content, commit it on the task branch with the task-prefixed "
"message, push the branch to origin, open the pull request "
"against the base branch, and self-verify every acceptance "
"criterion by re-reading the committed file content."
),
steps=[
{
"title": f"Write {filename}",
"description": (
f"Create {filename} at the repo root containing the "
"required content for the acceptance criteria."
),
},
{
"title": "Commit and push",
"description": (
"Commit the new file on the task branch with a "
"task-prefixed message and push it to origin."
),
},
{
"title": "Open PR and self-verify",
"description": (
"Open the pull request against the base branch and "
"re-read the file to confirm the criteria hold."
),
},
],
technical_considerations=["Plain text file; no build impact."],
risks=[
{
"risk": "None of substance — purely additive file.",
"mitigation": "Self-verify the file content before submit.",
}
],
open_questions=[],
)
# Real choreography: the composed claim succeeds and stays; the
# post-claim tracing gate demands the claim-time note; the retry
# short-circuits as re-entry.
expect_error(_claim(), "tracing_gap", "dev first i_will_work_on")
expect_ok(
dev.do(
"note",
scope="note",
task_id=tid,
text=(
"Initial assessment: a single additive text file at the repo "
"root satisfies the acceptance criteria; no existing code is "
"touched, so risk is minimal and the plan is a three-step "
"write/commit/PR sequence."
),
),
"dev note at claim",
)
expect_ok(_claim(), "dev i_will_work_on retry")
workspace = stack.workspace_of(project_slug, "backend", "be-dev-1")
workdir = workspace / ".worktrees" / tid[:8]
assert workdir.is_dir(), f"per-task worktree missing at {workdir}"
(workdir / filename).write_text(content)
expect_ok(
dev.do(
"commit",
message=f"feat: add {filename} with the required greeting content",
files=[filename],
),
"dev commit",
)
expect_ok(
dev.do(
"note",
scope="note",
task_id=tid,
text=(
f"{filename} written and committed on the task branch; "
"opening the PR next, then self-verifying the acceptance "
"criteria before submit."
),
),
"dev progress note",
)
env = expect_ok(dev.flow("open_pr", task_id=tid), "dev open_pr")
assert task_state(stack, task_id)["pr_number"], f"no PR recorded: {env}"
criteria = _criteria_text(stack, task_id)
expect_ok(
dev.do(
"note",
scope="decision",
task_id=tid,
text=(
"Verified every acceptance criterion on the branch: "
+ criteria
+ " — all hold against the committed content. Decision: no "
"further changes needed; the file is self-contained."
),
),
"dev during-work decision note",
)
expect_ok(
dev.do(
"note",
text="Handoff summary below (section carries the content).",
scope="handoff",
task_id=tid,
section={
"summary": (
f"Built {filename} at the repo root on the task branch; "
"PR is open against the base branch; single additive "
"commit, no risks beyond trivial content review."
)
},
),
"dev handoff section",
)
expect_ok(
dev.do(
"note",
scope="reflect",
task_id=tid,
text=(
"Reflection: implemented the task exactly per plan — wrote "
"the file, committed on the task branch, opened the PR, and "
"self-verified the acceptance criteria against the committed "
"content."
),
),
"dev reflect note",
)
expect_ok(dev.flow("i_am_done", task_id=tid), "dev i_am_done")
assert task_state(stack, task_id)["status"] == "awaiting_qa"
def _criteria_text(stack: E2EStack, task_id: Any) -> str:
from roboco.db.tables import TaskTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> list[str]:
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
return list(row.acceptance_criteria or [])
crits: list[str] = stack.run_db(_run)
return "; ".join(f'"{c}"' for c in crits)
def qa_arc(stack: E2EStack, company: Company, task_id: Any) -> None:
"""awaiting_qa → awaiting_documentation."""
tid = str(task_id)
qa = ScriptedAgent(stack, company.qa_id, "be-qa", "qa")
expect_ok(qa.flow("claim_review", task_id=tid), "qa claim_review")
expect_ok(
qa.do(
"note",
scope="learning",
task_id=tid,
text=(
"Review learning: the change is a single additive file; diff "
"inspection on the PR confirms the acceptance criteria with "
"no side effects on existing files."
),
),
"qa learning note",
)
async def _crits(session: AsyncSession) -> list[str]:
from roboco.db.tables import TaskTable
from sqlalchemy import select
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
return list(row.acceptance_criteria or [])
criteria: list[str] = stack.run_db(_crits)
expect_ok(
qa.flow(
"pass_review",
task_id=tid,
notes=(
"Verified the PR diff on the origin: the committed change "
"satisfies every acceptance criterion; no regressions in the "
"diff, and the branch contains exactly the described commit."
),
ac_verdicts=[
f"{c} — verified against the PR diff on the origin." for c in criteria
],
),
"qa pass_review",
)
assert task_state(stack, task_id)["status"] == "awaiting_documentation"
def doc_arc(stack: E2EStack, company: Company, task_id: Any, *, filename: str) -> None:
"""awaiting_documentation → awaiting_pm_review."""
tid = str(task_id)
doc = ScriptedAgent(stack, company.doc_id, "be-doc", "documenter")
expect_ok(doc.flow("claim_doc_task", task_id=tid), "doc claim_doc_task")
expect_ok(
doc.flow(
"i_documented",
task_id=tid,
files=[filename],
notes=(
f"Documented the change: {filename} carries the user-facing "
"content; no API surface changed, README untouched by design."
),
),
"doc i_documented",
)
state = task_state(stack, task_id)
assert state["status"] == "awaiting_pm_review", state
assert state["docs_complete"] is True, state
def seed_hierarchy(
stack: E2EStack, company: Company, project_id: Any
) -> dict[str, Any]:
"""Root (Main-PM) → cell (cell-PM) → dev child, seeded mid-flight.
Branch names follow the real convention (the task-short-id chain); the
PM planning/delegation lane is a later scenario's subject.
"""
from roboco.models import Team
from roboco.models.base import TaskStatus, TaskType
root_id = uuid4()
cell_id = uuid4()
root_branch = f"feature/backend/{str(root_id)[:8]}"
cell_branch = f"{root_branch}--{str(cell_id)[:8]}"
origin_branch(stack, root_branch, start="master")
origin_branch(stack, cell_branch, start=root_branch)
seed_task(
stack,
id=root_id,
title="Delivery root: greeting program",
description=(
"Root coordination task assembling the greeting feature across "
"the backend cell for the smoke harness merge-chain scenarios."
),
acceptance_criteria=["the greeting feature lands on the root branch"],
task_type=TaskType.PLANNING,
# A delivery root belongs to the Main PM's lane — team routing
# (closure, revision, reassignment) keys on this.
team=Team.MAIN_PM,
project_id=project_id,
created_by=company.main_pm_id,
assigned_to=company.main_pm_id,
status=TaskStatus.IN_PROGRESS,
branch_name=root_branch,
active_claimant_id=company.main_pm_id,
)
seed_task(
stack,
id=cell_id,
title="Backend slice: greeting file",
description=(
"Cell task assembling the backend slice of the greeting feature; "
"one dev leaf writes the file, the cell PM assembles and submits."
),
acceptance_criteria=["hello.txt exists at the repo root"],
task_type=TaskType.PLANNING,
project_id=project_id,
created_by=company.main_pm_id,
assigned_to=company.cell_pm_id,
parent_task_id=root_id,
status=TaskStatus.IN_PROGRESS,
branch_name=cell_branch,
active_claimant_id=company.cell_pm_id,
)
child_id = seed_task(
stack,
title="Write hello.txt",
description=(
"Create hello.txt with a friendly greeting at the repo root so "
"the merge-chain scenario has a real change to assemble upward."
),
acceptance_criteria=["hello.txt exists at the repo root"],
project_id=project_id,
created_by=company.cell_pm_id,
parent_task_id=cell_id,
assigned_to=company.dev_id,
)
return {
"root_id": root_id,
"root_branch": root_branch,
"cell_id": cell_id,
"cell_branch": cell_branch,
"child_id": child_id,
}
def reviewer_gate_pass_arc(stack: E2EStack, company: Company, task_id: Any) -> None:
"""awaiting_pr_review → awaiting_pm_review via the in-path gate."""
reviewer = ScriptedAgent(
stack, company.pr_reviewer_id, "pr-reviewer-1", "pr_reviewer"
)
expect_ok(
reviewer.flow("claim_gate_review", task_id=str(task_id)),
"reviewer claim_gate_review",
)
expect_ok(
reviewer.do(
"note",
scope="learning",
task_id=str(task_id),
text=(
"Gate review learning: the assembled diff is exactly the "
"child's additive file with the integrity marker present; "
"squash-merge assembly verified against the base branch."
),
),
"reviewer learning note",
)
expect_ok(
reviewer.flow(
"pr_pass",
task_id=str(task_id),
notes=(
"Assembled diff reviewed against the base branch: exactly the "
"expected additive change, integrity markers present, no "
"scope creep — passing to the PM for merge."
),
),
"reviewer pr_pass",
)
assert task_state(stack, task_id)["status"] == "awaiting_pm_review"
+7
View File
@@ -159,6 +159,9 @@ def _fake_github_router(gh: _FakeGitHub) -> APIRouter:
pr = gh.prs.get(number) pr = gh.prs.get(number)
if pr is None: if pr is None:
return JSONResponse({"message": "Not Found"}, status_code=404) return JSONResponse({"message": "Not Found"}, status_code=404)
# Real GitHub recomputes head.sha as the branch advances; a stale
# creation-time snapshot broke the unchanged-PR gate's semantics.
pr["head"]["sha"] = gh._sha_of(pr["head"]["ref"])
return JSONResponse(pr) return JSONResponse(pr)
@r.get("/repos/{owner}/{repo}/pulls") @r.get("/repos/{owner}/{repo}/pulls")
@@ -302,6 +305,7 @@ def _make_admin_clone(root: Path, origin: Path) -> Path:
def _build_app(gh: _FakeGitHub) -> FastAPI: def _build_app(gh: _FakeGitHub) -> FastAPI:
from roboco.api.middleware import setup_middleware from roboco.api.middleware import setup_middleware
from roboco.api.routes.health import router as health_router from roboco.api.routes.health import router as health_router
from roboco.api.routes.tasks import router as tasks_router
from roboco.api.routes.v1 import do as do_module from roboco.api.routes.v1 import do as do_module
from roboco.api.routes.v1 import flow_auditor as fa from roboco.api.routes.v1 import flow_auditor as fa
from roboco.api.routes.v1 import flow_board as fb from roboco.api.routes.v1 import flow_board as fb
@@ -318,6 +322,9 @@ def _build_app(gh: _FakeGitHub) -> FastAPI:
for module in (fd, fq, fdoc, fcp, fmp, fb, fa, fpr): for module in (fd, fq, fdoc, fcp, fmp, fb, fa, fpr):
app.include_router(module.router) app.include_router(module.router)
app.include_router(do_module.router) app.include_router(do_module.router)
# The REST task surface — scenario 3 drives the real CEO
# approve-and-merge endpoint (the human gate) through it.
app.include_router(tasks_router, prefix="/api/tasks")
app.include_router(_fake_github_router(gh)) app.include_router(_fake_github_router(gh))
return app return app
+36 -348
View File
@@ -2,368 +2,56 @@
Every hop goes through the REAL MCP tool functions real HTTP real Every hop goes through the REAL MCP tool functions real HTTP real
gateway gates real services real git against the local origin, with a gateway gates real services real git against the local origin, with a
fake GitHub REST layer whose merges are real git merges. No LLM: this file fake GitHub REST layer whose merges are real git merges. No LLM: the arcs
IS the agent script, and every rejection envelope is printed verbatim so a in ``tests/e2e_smoke/arcs.py`` ARE the agent script, and every rejection
seam regression names itself. envelope prints verbatim so a seam regression names itself.
""" """
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
from uuid import uuid4
import pytest from tests.e2e_smoke.arcs import (
from tests.e2e_smoke.harness import ( dev_arc,
E2EStack, doc_arc,
ScriptedAgent, qa_arc,
expect_error, seed_company,
expect_ok, seed_project,
seed_task,
task_state,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession from tests.e2e_smoke.harness import E2EStack
pytestmark = pytest.mark.usefixtures("e2e_stack")
_PROJECT_SLUG = "e2e-proj"
class _Company:
dev_id: Any
qa_id: Any
doc_id: Any
cell_pm_id: Any
project_id: Any
task_id: Any
def _seed(stack: E2EStack) -> _Company:
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.utils.crypto import encrypt_token
out = _Company()
async def _run(session: AsyncSession) -> None:
def agent(slug: str, role: AgentRole) -> AgentTable:
row = AgentTable(
id=uuid4(),
name=slug,
slug=slug,
role=role,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt=slug,
capabilities=[],
permissions={},
metrics={},
)
session.add(row)
return row
dev = agent("be-dev-1", AgentRole.DEVELOPER)
qa = agent("be-qa", AgentRole.QA)
doc = agent("be-doc", AgentRole.DOCUMENTER)
pm = agent("be-pm", AgentRole.CELL_PM)
await session.flush()
project = ProjectTable(
id=uuid4(),
name="E2E Project",
slug=_PROJECT_SLUG,
git_url=str(stack.origin),
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=pm.id,
is_active=True,
git_token_encrypted=encrypt_token("e2e-dummy-token"),
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title="Add the greeting module",
description=(
"Create greeting.txt with a friendly greeting so the smoke "
"harness has a real file change to commit, push, and merge."
),
acceptance_criteria=[
"greeting.txt exists at the repo root",
"its content greets the reader",
],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=project.id,
created_by=pm.id,
team=Team.BACKEND,
confirmed_by_human=True,
# The pool→agent routing lane is the orchestrator dispatcher's
# job (not under test here); a dev container is always spawned
# with its task already routed, which give_me_work serves via
# the pre-assigned-pending lane.
assigned_to=dev.id,
)
session.add(task)
await session.flush()
out.dev_id = dev.id
out.qa_id = qa.id
out.doc_id = doc.id
out.cell_pm_id = pm.id
out.project_id = project.id
out.task_id = task.id
stack.run_db(_run)
return out
def _task_state(stack: E2EStack, task_id: Any) -> dict[str, Any]:
from roboco.db.tables import TaskTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> dict[str, Any]:
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
return {
"status": str(row.status),
"branch_name": row.branch_name,
"pr_number": row.pr_number,
"docs_complete": row.docs_complete,
"assigned_to": row.assigned_to,
}
state: dict[str, Any] = stack.run_db(_run)
return state
def test_leaf_dev_task_reaches_pm_review(e2e_stack: E2EStack) -> None: def test_leaf_dev_task_reaches_pm_review(e2e_stack: E2EStack) -> None:
stack = e2e_stack stack = e2e_stack
ids = _seed(stack) company = seed_company(stack)
task_id = str(ids.task_id) project_id, project_slug = seed_project(stack, company)
task_id = seed_task(
# --- developer: discover, claim, work, PR, submit ----------------------- stack,
dev = ScriptedAgent(stack, ids.dev_id, "be-dev-1", "developer") title="Add the greeting module",
description=(
env = expect_ok(dev.flow("give_me_work"), "dev give_me_work") "Create greeting.txt with a friendly greeting so the smoke "
assert env.get("task_id") == task_id, f"expected our task, got: {env}" "harness has a real file change to commit, push, and merge."
def _claim() -> dict:
return dev.flow(
"i_will_work_on",
task_id=task_id,
plan=(
"Create greeting.txt at the repository root containing a "
"friendly greeting, commit it on the task branch with the "
"task-prefixed message, push the branch to origin, open the "
"pull request against master, and self-verify both acceptance "
"criteria by re-reading the committed file content."
),
steps=[
{
"title": "Write greeting.txt",
"description": (
"Create greeting.txt at the repo root containing a "
"friendly greeting for the reader."
),
},
{
"title": "Commit and push",
"description": (
"Commit the new file on the task branch with a "
"task-prefixed message and push it to origin."
),
},
{
"title": "Open PR and self-verify",
"description": (
"Open the pull request against master and re-read the "
"file to confirm both acceptance criteria hold."
),
},
],
technical_considerations=["Plain text file; no build impact."],
risks=[
{
"risk": "None of substance — purely additive file.",
"mitigation": "Self-verify the file content before submit.",
}
],
open_questions=[],
)
# The composed claim succeeds and STAYS; the post-claim tracing gate
# then demands the claim-time journal note — the real agent choreography
# is claim → tracing_gap → note (now claim-held) → retry short-circuits.
expect_error(_claim(), "tracing_gap", "dev first i_will_work_on")
expect_ok(
dev.do(
"note",
scope="note",
task_id=task_id,
text=(
"Initial assessment: a single additive text file at the repo "
"root satisfies both acceptance criteria; no existing code is "
"touched, so risk is minimal and the plan is a three-step "
"write/commit/PR sequence."
),
), ),
"dev note at claim", acceptance_criteria=[
) "greeting.txt exists at the repo root",
expect_ok(_claim(), "dev i_will_work_on retry") "its content greets the reader",
state = _task_state(stack, ids.task_id) ],
assert state["status"] in ("claimed", "in_progress"), state project_id=project_id,
assert state["branch_name"], f"claim did not set a branch: {state}" created_by=company.cell_pm_id,
# Pool→agent routing is the orchestrator dispatcher's job (not under
workspace = stack.workspace_of(_PROJECT_SLUG, "backend", "be-dev-1") # test); a dev container is always spawned with its task already
assert workspace.is_dir(), f"workspace clone missing at {workspace}" # routed, which give_me_work serves via the pre-assigned lane.
# F123: the agent works in the per-task worktree, not the clone root. assigned_to=company.dev_id,
workdir = workspace / ".worktrees" / task_id[:8]
assert workdir.is_dir(), f"per-task worktree missing at {workdir}"
(workdir / "greeting.txt").write_text("Hello from the e2e smoke agent!\n")
expect_ok(
dev.do(
"commit",
message="Add greeting.txt with a friendly greeting",
files=["greeting.txt"],
),
"dev commit",
)
expect_ok(
dev.do(
"note",
scope="note",
task_id=task_id,
text=(
"greeting.txt written and committed on the task branch; "
"opening the PR next, then self-verifying the acceptance "
"criteria before submit."
),
),
"dev progress note",
)
env = expect_ok(dev.flow("open_pr", task_id=task_id), "dev open_pr")
state = _task_state(stack, ids.task_id)
assert state["pr_number"], f"open_pr did not record a PR: {state} / {env}"
# The i_am_done tracing gate demands: a during-work journal entry, the
# dev_notes handoff section, a reflect entry, and an artifact referencing
# every acceptance criterion (quoted verbatim in the decision note).
expect_ok(
dev.do(
"note",
scope="decision",
task_id=task_id,
text=(
"Verified both acceptance criteria on the branch: "
'"greeting.txt exists at the repo root" holds (file committed '
'at the root), and "its content greets the reader" holds '
"(content is a friendly hello). Decision: no README change "
"needed; the greeting file is self-contained."
),
),
"dev during-work decision note",
)
expect_ok(
dev.do(
"note",
text="Handoff summary below (section carries the content).",
scope="handoff",
task_id=task_id,
section={
"summary": (
"Built the greeting module: greeting.txt added at the "
"repo root with a friendly greeting. Key change is one "
"additive file on the task branch; PR is open against "
"master; no risks beyond trivial content review."
)
},
),
"dev handoff section",
)
expect_ok(
dev.do(
"note",
scope="reflect",
task_id=task_id,
text=(
"Reflection: implemented the greeting task exactly per plan — "
"wrote the file, committed on the task branch, opened the PR, "
"and self-verified both acceptance criteria against the "
"committed content."
),
),
"dev reflect note",
)
expect_ok(dev.flow("i_am_done", task_id=task_id), "dev i_am_done")
assert _task_state(stack, ids.task_id)["status"] == "awaiting_qa"
# --- QA: claim the review, inspect, pass --------------------------------
qa = ScriptedAgent(stack, ids.qa_id, "be-qa", "qa")
expect_ok(qa.flow("claim_review", task_id=task_id), "qa claim_review")
expect_ok(
qa.do(
"note",
scope="learning",
task_id=task_id,
text=(
"Review learning: the greeting change is a single additive "
"file; diff inspection on the PR confirms both acceptance "
"criteria with no side effects on existing files."
),
),
"qa learning note",
)
expect_ok(
qa.flow(
"pass_review",
task_id=task_id,
notes=(
"Verified the PR diff on the fake origin: greeting.txt exists "
"at the repo root and greets the reader. Both acceptance "
"criteria hold; no regressions in the diff, and the branch "
"contains exactly the one additive commit described."
),
ac_verdicts=[
(
"greeting.txt exists at the repo root — verified in the "
"PR diff: the file is added at the repository root."
),
(
"its content greets the reader — verified: the committed "
"content is a friendly hello message."
),
],
),
"qa pass_review",
)
assert _task_state(stack, ids.task_id)["status"] == "awaiting_documentation"
# --- documenter: claim, document -----------------------------------------
doc = ScriptedAgent(stack, ids.doc_id, "be-doc", "documenter")
expect_ok(doc.flow("claim_doc_task", task_id=task_id), "doc claim_doc_task")
expect_ok(
doc.flow(
"i_documented",
task_id=task_id,
files=["greeting.txt"],
notes=(
"Documented the greeting module: greeting.txt carries the "
"user-facing greeting; no API surface changed, README "
"untouched by design."
),
),
"doc i_documented",
) )
final = _task_state(stack, ids.task_id) dev_arc(stack, company, project_slug, task_id)
qa_arc(stack, company, task_id)
doc_arc(stack, company, task_id, filename="greeting.txt")
final = task_state(stack, task_id)
assert final["status"] == "awaiting_pm_review", final assert final["status"] == "awaiting_pm_review", final
assert final["docs_complete"] is True, final assert final["docs_complete"] is True, final
+175
View File
@@ -0,0 +1,175 @@
"""Scenarios 2 + 2b: the PM merge chain, with and without the submit turn.
Scenario 2 (the BEFORE-net): the classic chain the cell PM completes the
child, calls ``submit_up`` itself, the reviewer gate-passes, the PM merges.
Scenario 2b (the turn cut): the child lands the same way, but the SUBMIT
turn never happens as an agent call the orchestrator's
``_try_auto_submit`` runs the real submit verb through the real API as the
owning PM, and the chain continues reviewer PM merge. One PM turn fewer
per assembled parent, with every gate intact.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
import httpx
from tests.e2e_smoke.arcs import (
dev_arc,
dispatcher_assign,
doc_arc,
origin_file,
qa_arc,
reviewer_gate_pass_arc,
seed_company,
seed_hierarchy,
seed_project,
task_state,
)
from tests.e2e_smoke.harness import ScriptedAgent, expect_ok
if TYPE_CHECKING:
import pytest
from tests.e2e_smoke.arcs import Company
from tests.e2e_smoke.harness import E2EStack
def _land_child(
stack: E2EStack, company: Company, project_slug: str, h: dict
) -> ScriptedAgent:
"""Run the child through dev→QA→doc and the PM's child-completion merge."""
dev_arc(
stack,
company,
project_slug,
h["child_id"],
work=("hello.txt", "Hello from the merge chain!\n"),
)
qa_arc(stack, company, h["child_id"])
doc_arc(stack, company, h["child_id"], filename="hello.txt")
# The child's PR must target the CELL branch (ancestor resolution) —
# branch NAMES derive from the task-id chain, so assert on the base ref.
child = task_state(stack, h["child_id"])
child_pr = stack.github.prs[child["pr_number"]]
assert child_pr["base"]["ref"] == h["cell_branch"], (
f"child PR should target the cell branch: {child_pr['base']} / {child}"
)
pm = ScriptedAgent(stack, company.cell_pm_id, "be-pm", "cell_pm")
expect_ok(
pm.flow(
"complete",
task_id=str(h["child_id"]),
notes=(
"Child verified: QA passed with per-criterion verdicts and "
"docs are complete; merging the leaf PR into the cell branch."
),
),
"pm complete child",
)
assert task_state(stack, h["child_id"])["status"] == "completed"
assert origin_file(stack, h["cell_branch"], "hello.txt"), (
"child merge did not land hello.txt on the cell branch"
)
return pm
def _pm_merges_cell(
stack: E2EStack, company: Company, pm: ScriptedAgent, h: dict
) -> None:
"""Dispatcher re-claim (mirrored) + the PM's final merge turn."""
dispatcher_assign(stack, h["cell_id"], company.cell_pm_id)
expect_ok(
pm.flow(
"complete",
task_id=str(h["cell_id"]),
notes=(
"Gate passed; merging the assembled cell PR into the root "
"branch and closing the cell task."
),
),
"pm complete cell",
)
assert task_state(stack, h["cell_id"])["status"] == "completed"
assert origin_file(stack, h["root_branch"], "hello.txt"), (
"cell merge did not land hello.txt on the root branch"
)
def test_pm_merge_chain_to_root_branch(e2e_stack: E2EStack) -> None:
stack = e2e_stack
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
h = seed_hierarchy(stack, company, project_id)
pm = _land_child(stack, company, project_slug, h)
# --- cell PM: submit the assembled cell PR (the turn 2b cuts) -----------
expect_ok(
pm.flow(
"submit_up",
task_id=str(h["cell_id"]),
notes=(
"All children terminal and merged into the cell branch; "
"assembling the cell PR against the root branch for the "
"in-path review gate."
),
),
"pm submit_up",
)
cell = task_state(stack, h["cell_id"])
assert cell["status"] == "awaiting_pr_review", cell
assert cell["pr_number"], cell
reviewer_gate_pass_arc(stack, company, h["cell_id"])
_pm_merges_cell(stack, company, pm, h)
def test_auto_submit_cuts_the_pm_turn(
e2e_stack: E2EStack, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The wave-1 turn cut, end to end: no agent calls submit_up — the
orchestrator's ``_try_auto_submit`` drives the REAL submit verb through
the REAL API as the owning PM, and the gate chain continues unchanged."""
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
stack = e2e_stack
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
h = seed_hierarchy(stack, company, project_id)
pm = _land_child(stack, company, project_slug, h)
# --- the cut: the orchestrator submits system-side ----------------------
monkeypatch.setattr(settings, "api_url", stack.base_url)
monkeypatch.setattr(settings, "pr_gate_auto_submit_enabled", True)
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._tick_handled_tasks = set()
orch._bg_tasks = set()
cell_task_dict = {
"id": str(h["cell_id"]),
"team": "backend",
"branch_name": h["cell_branch"],
"project_id": str(project_id),
"assigned_to": str(company.cell_pm_id),
"status": "in_progress",
}
async def _go() -> bool:
async with httpx.AsyncClient(timeout=60) as client:
return await orch._try_auto_submit(client, cell_task_dict, "be-pm")
assert asyncio.run(_go()) is True, "auto-submit should accept a clean parent"
cell = task_state(stack, h["cell_id"])
assert cell["status"] == "awaiting_pr_review", cell
assert cell["pr_number"], cell
# --- unchanged tail: reviewer gate + the PM's one remaining turn ---------
reviewer_gate_pass_arc(stack, company, h["cell_id"])
_pm_merges_cell(stack, company, pm, h)
+227
View File
@@ -0,0 +1,227 @@
"""Scenario 3: the pr_fail revision loop and the root → CEO chain.
3a: the reviewer REJECTS the assembled cell PR (`pr_fail` with concrete
issues) needs_revision; the PM resumes, re-submits, and the second gate
pass rides through to the merge the loop the live fleet burned tokens on
when any link mis-routed.
3b: after the cell lands on the root branch, the Main PM submits the root
(root master PR), the reviewer gate-passes it, the Main PM's `complete`
escalates the root parent to the CEO, and the REAL CEO endpoint
(`POST /api/tasks/{id}/approve-and-merge`) squash-merges to master
`hello.txt` ends up on the origin's master, the whole company loop closed
with no LLM anywhere.
"""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
import httpx
from tests.e2e_smoke.arcs import (
dispatcher_assign,
origin_commit,
origin_file,
reviewer_gate_pass_arc,
seed_company,
seed_hierarchy,
seed_project,
task_state,
)
from tests.e2e_smoke.harness import ScriptedAgent, expect_ok
from tests.e2e_smoke.test_pm_merge_chain import _land_child, _pm_merges_cell
if TYPE_CHECKING:
from tests.e2e_smoke.harness import E2EStack
def test_pr_fail_revision_loop(e2e_stack: E2EStack) -> None:
stack = e2e_stack
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
h = seed_hierarchy(stack, company, project_id)
pm = _land_child(stack, company, project_slug, h)
cell_id = str(h["cell_id"])
expect_ok(
pm.flow(
"submit_up",
task_id=cell_id,
notes=(
"All children terminal and merged into the cell branch; "
"assembling the cell PR for the in-path review gate."
),
),
"pm submit_up (first)",
)
reviewer = ScriptedAgent(
stack, company.pr_reviewer_id, "pr-reviewer-1", "pr_reviewer"
)
expect_ok(
reviewer.flow("claim_gate_review", task_id=cell_id),
"reviewer claim_gate_review (first)",
)
expect_ok(
reviewer.do(
"note",
scope="learning",
task_id=cell_id,
text=(
"Gate review learning: the assembled diff is missing a "
"trailing newline convention the root branch enforces — "
"sending back with a concrete fix."
),
),
"reviewer learning note (fail pass)",
)
expect_ok(
reviewer.flow(
"pr_fail",
task_id=cell_id,
issues=[
"hello.txt should end with exactly one trailing newline "
"per the root branch's file conventions."
],
),
"reviewer pr_fail",
)
assert task_state(stack, h["cell_id"])["status"] == "needs_revision"
# The revision dispatcher routes the assembled task back to its PM;
# mirror that hand-back, then the PM resumes and re-submits.
dispatcher_assign(stack, h["cell_id"], company.cell_pm_id)
expect_ok(
pm.flow(
"i_will_plan",
task_id=cell_id,
plan=(
"Address the gate's concrete issue and re-submit the cell PR "
"for a clean pass through the in-path review gate."
),
approach=(
"Take the reviewer's single concrete finding — hello.txt must "
"end with exactly one trailing newline per the root branch's "
"file conventions — verify the file on the cell branch already "
"satisfies it, re-check the assembled diff against the root "
"branch for any other convention drift, and then re-run "
"submit_up so the gate reviews a corrected, freshly assembled "
"cell PR."
),
sub_tasks=[
{
"title": "Verify the newline convention",
"description": (
"Confirm hello.txt on the cell branch ends with exactly "
"one trailing newline as the reviewer's finding requires."
),
},
{
"title": "Re-submit the assembled PR",
"description": (
"Run submit_up again so the freshness and integrity "
"checks re-assemble the cell PR for a clean gate pass."
),
},
],
),
"pm i_will_plan after pr_fail",
)
# The unchanged-PR hard gate (0.14.0) refuses a resubmit until new work
# advances the cell branch HEAD — land the dev's fix, then resubmit.
origin_commit(
stack,
h["cell_branch"],
"hello.txt",
"Hello from the merge chain, with tidy newline conventions!\n",
f"[{str(h['child_id'])[:8]}] fix: normalize hello.txt trailing newline",
)
expect_ok(
pm.flow(
"submit_up",
task_id=cell_id,
notes=(
"Revision addressed: file conventions verified against the "
"root branch; re-assembling the cell PR for the gate."
),
),
"pm submit_up (resubmit)",
)
assert task_state(stack, h["cell_id"])["status"] == "awaiting_pr_review"
reviewer_gate_pass_arc(stack, company, h["cell_id"])
_pm_merges_cell(stack, company, pm, h)
def test_root_chain_lands_on_master_via_ceo(e2e_stack: E2EStack) -> None:
stack = e2e_stack
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
h = seed_hierarchy(stack, company, project_id)
# Cell lands on the root branch exactly as scenario 2 proved.
pm = _land_child(stack, company, project_slug, h)
expect_ok(
pm.flow(
"submit_up",
task_id=str(h["cell_id"]),
notes=(
"All children terminal and merged into the cell branch; "
"assembling the cell PR for the in-path review gate."
),
),
"pm submit_up",
)
reviewer_gate_pass_arc(stack, company, h["cell_id"])
_pm_merges_cell(stack, company, pm, h)
# --- Main PM: submit the root → master PR, gate, complete → escalate ----
main_pm = ScriptedAgent(stack, company.main_pm_id, "main-pm", "main_pm")
root_id = str(h["root_id"])
expect_ok(
main_pm.flow(
"submit_root",
task_id=root_id,
notes=(
"Every cell task is terminal and assembled on the root "
"branch; opening the root PR against master for the gate."
),
),
"main_pm submit_root",
)
root = task_state(stack, h["root_id"])
assert root["status"] == "awaiting_pr_review", root
assert root["pr_number"], root
reviewer_gate_pass_arc(stack, company, h["root_id"])
dispatcher_assign(stack, h["root_id"], company.main_pm_id)
expect_ok(
main_pm.flow(
"complete",
task_id=root_id,
notes=(
"Gate passed on the assembled root PR; approving the root "
"parent and escalating to the CEO for the merge decision."
),
),
"main_pm complete root",
)
assert task_state(stack, h["root_id"])["status"] == "awaiting_ceo_approval"
# --- the human gate: the REAL CEO endpoint merges to master --------------
resp = httpx.post(
f"{stack.base_url}/api/tasks/{root_id}/approve-and-merge",
headers={
"X-Agent-ID": str(company.ceo_id),
"X-Agent-Role": "ceo",
},
timeout=60,
)
assert resp.status_code == HTTPStatus.OK, (
f"approve-and-merge: {resp.status_code} {resp.text[:1500]}"
)
assert task_state(stack, h["root_id"])["status"] == "completed"
assert origin_file(stack, "master", "hello.txt"), (
"the CEO merge did not land hello.txt on master"
)
@@ -159,3 +159,37 @@ async def test_state_allows_secretary(monkeypatch: pytest.MonkeyPatch) -> None:
_install(monkeypatch, _FakeService()) _install(monkeypatch, _FakeService())
resp = await sec_route.read_state(_db(), _agent(AgentRole.SECRETARY)) resp = await sec_route.read_state(_db(), _agent(AgentRole.SECRETARY))
assert resp.pending_pitches == [] assert resp.pending_pitches == []
@pytest.mark.asyncio
async def test_search_tasks_forbidden_for_developer() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.search_tasks(_db(), _agent(AgentRole.DEVELOPER), q="greeting")
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_search_tasks_returns_compact_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The CEO refers to tasks by NAME in the Secretary chat — the search
resolves names to ids so a directive can target the right task."""
row = MagicMock()
row.id = uuid4()
row.title = "Rework the greeting banner"
row.status = "pending"
row.team = "backend"
row.priority = 2
task_svc = MagicMock()
task_svc.search_tasks = AsyncMock(return_value=[row])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _db: task_svc)
out = await sec_route.search_tasks(_db(), _agent(AgentRole.SECRETARY), q="greeting")
assert out == [
{
"id": str(row.id),
"title": "Rework the greeting banner",
"status": "pending",
"team": "backend",
"priority": 2,
}
]
+43
View File
@@ -3642,3 +3642,46 @@ async def test_pm_merge_auto_completes_without_double_completion(
# complete_task_for_agent must NOT have been called: the task was already # complete_task_for_agent must NOT have been called: the task was already
# auto-completed by _auto_complete_on_merge inside merge_pr_for_task. # auto-completed by _auto_complete_on_merge inside merge_pr_for_task.
complete_for_agent_spy.assert_not_called() complete_for_agent_spy.assert_not_called()
@pytest.mark.asyncio
async def test_summary_search_matches_title_description_and_id(
task_client: dict,
) -> None:
"""The task list search covers title, description (details/keywords),
and id prefix server-side, because summaries deliberately exclude
descriptions (CEO reMarkable item: task search bar)."""
client = task_client["client"]
hit_title = _seed_task(task_client, title="Rework the greeting banner")
hit_desc = _seed_task(
task_client,
title="Unrelated title",
description="Contains the zanzibar keyword deep in the details.",
)
miss = _seed_task(task_client, title="Nothing to see here")
await task_client["db"].flush()
by_title = await client.get("/api/tasks/summary?q=greeting", headers=_HDR)
assert by_title.status_code == HTTPStatus.OK
ids = {t["id"] for t in by_title.json()}
assert str(hit_title.id) in ids and str(miss.id) not in ids
by_desc = await client.get("/api/tasks/summary?q=zanzibar", headers=_HDR)
ids = {t["id"] for t in by_desc.json()}
assert str(hit_desc.id) in ids and str(hit_title.id) not in ids
prefix = str(hit_title.id)[:8]
by_id = await client.get(f"/api/tasks/summary?q={prefix}", headers=_HDR)
ids = {t["id"] for t in by_id.json()}
assert str(hit_title.id) in ids
@pytest.mark.asyncio
async def test_summary_search_respects_team_filter(task_client: dict) -> None:
client = task_client["client"]
hit = _seed_task(task_client, title="Backend greeting search hit")
await task_client["db"].flush()
resp = await client.get("/api/tasks/summary?q=greeting&team=frontend", headers=_HDR)
assert resp.status_code == HTTPStatus.OK
assert str(hit.id) not in {t["id"] for t in resp.json()}
@@ -0,0 +1,143 @@
"""The PR-gate turn cut: closure auto-submits assembled parents to the gate.
When every child of an assembled parent is terminal, the orchestrator used
to spawn the PM just to call submit_up/submit_root a whole agent turn
whose substance (freshness rebase, integrity check, PR open) is
deterministic gate code. ``_try_auto_submit`` runs the REAL submit verb
through the internal API as the owning PM; only a gate rejection falls
back to the classic PM closure spawn. The PM's remaining turn is the one
that needs judgment: the final merge (or the revision).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AGENT_UUIDS, AgentOrchestrator
# The commit/notes validator's minimum substantive length.
_MIN_NOTES = 20
_CELL_TASK: dict[str, Any] = {
"id": "11111111-1111-1111-1111-111111111111",
"team": "backend",
"branch_name": "feature/backend/AAAA1111",
"project_id": "22222222-2222-2222-2222-222222222222",
"assigned_to": "33333333-3333-3333-3333-333333333333",
"status": "in_progress",
}
def _orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._tick_handled_tasks = set()
orch._bg_tasks = set()
return orch
def _client(envelope: dict[str, Any]) -> MagicMock:
response = MagicMock()
response.json.return_value = envelope
client = MagicMock()
client.post = AsyncMock(return_value=response)
return client
@pytest.mark.asyncio
async def test_cell_parent_auto_submits_as_owning_pm(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client({"status": "awaiting_pr_review", "error": None})
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is True
(url,), kwargs = client.post.call_args
assert url == f"{orch._api_url}/v1/flow/cell_pm/submit_up"
assert kwargs["headers"]["X-Agent-ID"] == _CELL_TASK["assigned_to"]
assert kwargs["headers"]["X-Agent-Role"] == "cell_pm"
assert kwargs["json"]["task_id"] == _CELL_TASK["id"]
assert len(kwargs["json"]["notes"]) >= _MIN_NOTES
@pytest.mark.asyncio
async def test_main_pm_root_auto_submits_submit_root(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client({"status": "awaiting_pr_review", "error": None})
task = {**_CELL_TASK, "team": "main_pm"}
assert await orch._try_auto_submit(client, task, "main-pm") is True
(url,), kwargs = client.post.call_args
assert url == f"{orch._api_url}/v1/flow/main_pm/submit_root"
assert kwargs["headers"]["X-Agent-Role"] == "main_pm"
@pytest.mark.asyncio
async def test_branchless_parent_never_auto_submits(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A branchless coordination parent (MegaTask umbrella) assembles no PR."""
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client({"error": None})
task = {**_CELL_TASK, "branch_name": None}
assert await orch._try_auto_submit(client, task, "be-pm") is False
client.post.assert_not_called()
@pytest.mark.asyncio
async def test_flag_off_is_inert(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", False)
orch = _orch()
client = _client({"error": None})
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is False
client.post.assert_not_called()
@pytest.mark.asyncio
async def test_gate_rejection_falls_back_to_pm_spawn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A rejection envelope (e.g. integrity/freshness refusal) means the PM
turn is genuinely needed auto-submit yields to the closure spawn."""
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client(
{"error": "invalid_state", "message": "assembled branch behind base"}
)
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is False
client.post.assert_called_once()
@pytest.mark.asyncio
async def test_missing_assignment_falls_back_to_static_identity(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = _client({"status": "awaiting_pr_review", "error": None})
task = {**_CELL_TASK, "assigned_to": None}
assert await orch._try_auto_submit(client, task, "be-pm") is True
(_, kwargs) = client.post.call_args
assert kwargs["headers"]["X-Agent-ID"] == AGENT_UUIDS["be-pm"]
@pytest.mark.asyncio
async def test_transport_error_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "pr_gate_auto_submit_enabled", True)
orch = _orch()
client = MagicMock()
client.post = AsyncMock(side_effect=RuntimeError("api down"))
assert await orch._try_auto_submit(client, _CELL_TASK, "be-pm") is False
+38
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@@ -114,3 +115,40 @@ def test_content_type_for_role_none_for_sectionless_roles() -> None:
assert content_type_for_role("head_marketing") is None assert content_type_for_role("head_marketing") is None
assert content_type_for_role("ceo") is None assert content_type_for_role("ceo") is None
assert content_type_for_role("prompter") is None assert content_type_for_role("prompter") is None
def test_sections_carry_written_at_stamp() -> None:
"""Every persisted section carries an ISO written_at — traces without
timestamps were unusable for reconstructing WHEN a note landed (CEO
reMarkable item, 2026-07-02)."""
t = _task()
apply_structured_note(
t,
"developer",
{
"summary": (
"Built the greeting module end to end; single additive file "
"on the task branch with the PR open against the base."
)
},
)
stored = (t.notes_structured or {})["developer"]
assert "written_at" in stored, stored
# Parseable, timezone-aware ISO-8601.
parsed = datetime.fromisoformat(stored["written_at"])
assert parsed.tzinfo is not None
def test_written_at_refreshes_on_rewrite() -> None:
t = _task()
payload = {
"summary": (
"First pass of the notes section, long enough to validate "
"against the dev section's minimum content length."
)
}
apply_structured_note(t, "developer", payload)
first = (t.notes_structured or {})["developer"]["written_at"]
apply_structured_note(t, "developer", payload)
second = (t.notes_structured or {})["developer"]["written_at"]
assert second >= first
@@ -34,6 +34,7 @@ def _patch(monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
task = MagicMock() task = MagicMock()
task.approve_and_start = AsyncMock() task.approve_and_start = AsyncMock()
task.admin_set_status = AsyncMock() task.admin_set_status = AsyncMock()
task.update = AsyncMock()
monkeypatch.setattr(sec_module, "get_task_service", lambda _s: task) monkeypatch.setattr(sec_module, "get_task_service", lambda _s: task)
notifier = MagicMock() notifier = MagicMock()
notifier.send_ack_notification = AsyncMock() notifier.send_ack_notification = AsyncMock()
@@ -171,3 +172,55 @@ async def test_bad_task_action_fails_directive(
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row)) monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4()) out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.FAILED.value assert out.status == DirectiveStatus.FAILED.value
@pytest.mark.asyncio
async def test_confirm_control_task_edit_updates_allowlisted_fields(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The Secretary can MODIFY a task's content fields on CEO confirmation
(reMarkable item) restricted to the safe allowlist."""
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
tid = uuid4()
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(tid),
"action": "edit",
"fields": {
"title": "Sharper title",
"priority": 1,
"description": "Clarified description from the CEO chat.",
},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.EXECUTED.value
svcs["task"].update.assert_awaited_once()
_, kwargs = svcs["task"].update.await_args
assert kwargs["title"] == "Sharper title"
assert kwargs["priority"] == 1
@pytest.mark.asyncio
async def test_control_task_edit_rejects_non_allowlisted_fields(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Status/ownership/git fields never ride an edit — those have their own
audited paths (override, reassign)."""
svcs = _patch(monkeypatch)
svc = SecretaryService(_session())
row = _pending(
DirectiveKind.CONTROL_TASK,
{
"task_id": str(uuid4()),
"action": "edit",
"fields": {"status": "completed"},
},
)
monkeypatch.setattr(svc, "get_directive", AsyncMock(return_value=row))
out = await svc.confirm_directive(row.id, uuid4())
assert out.status == DirectiveStatus.FAILED.value
svcs["task"].update.assert_not_awaited()