Files
roboco/tests/e2e_smoke/test_megatask_umbrella.py
8f6dde9a50 feat(sandbox): pluggable per-engine registry (postgres/redis/mongo) (#324)
* feat(sandbox): pluggable per-engine registry (postgres/redis/mongo)

Replaces the hardcoded postgres+redis branches in the provisioner and the
env emitter with a registry of SandboxEngine specs (image, run args,
readiness probe, connection, ROBOCO_TEST_* env) in a pure low module
(roboco/models/sandbox.py). VALID_SANDBOX_SERVICES is derived from the
registry — single source of truth — and the provisioner + orchestrator
iterate it, so adding an engine is one class + one registry line, not
another branch. Adds a mongo:8-alpine engine (ROBOCO_TEST_MONGO_*) as the
third service alongside postgres/redis.

Also fixes the cold-pull loop that stranded v0.19.0 board agents with
empty error strings: docker run pulled inline under a 20s deadline, so a
NAS cold pull was killed, cancelled, and re-pulled from scratch forever.
_ensure_image now inspects + pulls (300s) before run; provisioning errors
log type+message so a bare TimeoutError no longer shows as "".

Panel edit-project dialog: postgres/redis toggles -> a Set<string>
multi-select driven by a SANDBOX_SERVICES catalog, so new engines appear
in the UI by adding to the catalog.

Tests: engine parity (allowlist==registry, unique slugs/images, no None
leak in env, SandboxInfo aggregates every engine), mongo provision + env
injection, plus the existing postgres/redis provision/env/spawn/janitor
suite updated to the registry shape. 821 unit / 5 skip green; ruff + mypy
(360 files) clean.

* docs(sandbox): reflect pluggable engine registry + mongo across docs

CHANGELOG (0.19.0): Added entry for the pluggable sandbox engine registry
(postgres/redis/mongo) + Fixed entry for the cold-pull loop/empty-error
strand that boarded v0.19.0 board agents.

docs/map (9 files): sandbox subsystem blurbs, SandboxProvisioner rows,
_maybe_provision_sandbox/_append_sandbox_env rows, feature-flag rows, the
migration-057 row + v0.17.0 delta, and the models.md VALID_SANDBOX_SERVICES
note — all retitled to DB/Redis/Mongo via the engine registry
(roboco/models/sandbox.py), with the one-class-one-line extension story and
the _ensure_image cold-pull fix. Production-network (roboco_data) lines left
as postgres+redis — mongo is sandbox-only, not a prod service.

docs/rag (3 files): sandbox-db.md rewritten around the registry (engine list,
generic _provision_engine, image pre-pull, ROBOCO_TEST_DB_*/REDIS_*/MONGO_*
incl. MONGO_AUTH_DB=admin, single emit_env); config-reference sandbox flag
row + subsection retitled; db-network-isolation framing broadened to
postgres/redis/mongo. preconditions-and-rejections left untouched (its hit
was an unrelated gateway see-also link).

* test(e2e): harden umbrella close terminal reads with bounded wait-for-state

The MegaTask umbrella close test flaked once on CI (ceo-approve returned
200 but the re-fetch saw awaiting_pm_review) then passed on re-run. The
production path is deterministic: complete -> main_pm_complete ->
submit_pm_review -> escalate_to_ceo -> ceo_approve -> commit, all on one
session, all awaited; the fire-and-forget completion hooks are isolated
(own session, best-effort, never touch task.status or the request session).
20 local runs could not reproduce it.

The one real surface is the read pattern: the e2e stack commits on the
uvicorn thread's loop and reads via a separate loop (run_db -> asyncio.run
with a fresh engine), so a terminal single point-read can race a
still-draining completion hook on a contended runner. Replace the two
terminal point-reads with a bounded wait_for_status poll. Strictly better
than a one-shot read: absorbs the transient, and a genuine state bug still
surfaces via the timeout branch asserting against the last-read state.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-07 13:59:53 +02:00

449 lines
17 KiB
Python

"""Scenario 4: the MegaTask umbrella — sequencing hold, serial root-subtask
completion, and the branchless umbrella close.
The umbrella (``batch_id`` set, ``parent_task_id=None``, no project/branch/PR
of its own) groups two root-subtasks (``batch_id`` set, parented under the
umbrella, each a real Main-PM delivery root with its own project/branch/PR —
``is_batch_root_subtask``). RS2 depends on RS1 (the serial-wave-chain edge
production wires via ``TaskService.add_dependency``). The scenario:
1. Proves the sequencing hold: RS2 cannot be planned while RS1 is open.
2. Drives RS1 through the REAL dev→QA→doc→cell→root→CEO chain to master.
3. Proves the hold clears once RS1 is terminal, then drives RS2 through the
identical chain (a genuinely serial MegaTask — RS1's root PR merges to
master before RS2 is ever claimed).
4. Closes the umbrella itself via the branchless path: ``complete`` escalates
it straight from ``in_progress`` (no ``submit_root`` — an umbrella
assembles no PR of its own) to ``awaiting_ceo_approval``, and the CEO
closes it with ``POST /ceo-approve`` (NOT ``approve-and-merge`` — there is
no PR to merge).
"""
from __future__ import annotations
from dataclasses import dataclass
from http import HTTPStatus
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import httpx
from tests.e2e_smoke.arcs import (
dev_arc,
dispatcher_assign,
doc_arc,
origin_branch,
origin_file,
qa_arc,
reviewer_gate_pass_arc,
seed_cell_and_dev,
seed_company,
seed_project,
seed_task,
set_branch_name,
task_state,
wait_for_status,
wire_dependency,
)
from tests.e2e_smoke.harness import ScriptedAgent, expect_error, expect_ok
if TYPE_CHECKING:
from tests.e2e_smoke.arcs import Company
from tests.e2e_smoke.harness import E2EStack
@dataclass
class World:
"""The constant per-test context, bundled so the seeding helpers below
stay under the argument-count lint bar."""
stack: E2EStack
company: Company
project_id: Any
project_slug: str
# Pydantic's IWillPlanRequest.approach enforces >= 150 chars at the HTTP
# boundary regardless of which internal gate would fire first, so every
# i_will_plan call (including the one expected to be REJECTED by the
# dependency guard) needs a compliant approach.
_APPROACH = (
"Serialize the two root-subtasks of this MegaTask: land RS1's cell work, "
"submit its root PR, pass the in-path review gate, escalate it to the "
"CEO, and merge to master before RS2 — whose branch depends on RS1's "
"completion — is released to repeat the identical sequence on its own "
"root branch against the same project's master."
)
def _seed_umbrella(stack: E2EStack, company: Company) -> tuple[Any, Any]:
"""Branchless MegaTask umbrella: no project/product/cell-map, no branch,
no PR of its own — ``is_batch_umbrella`` (batch_id set, no parent)."""
from roboco.models import Team
from roboco.models.base import TaskStatus, TaskType
batch_id = uuid4()
umbrella_id = seed_task(
stack,
title="MegaTask: ship the greeting feature in two waves",
description=(
"Umbrella coordinating two sequenced root-subtasks that each "
"land a greeting file on the same project's master branch, one "
"after the other."
),
acceptance_criteria=["every root-subtask lands on master"],
task_type=TaskType.PLANNING,
team=Team.MAIN_PM,
batch_id=batch_id,
parent_task_id=None,
created_by=company.main_pm_id,
assigned_to=company.main_pm_id,
status=TaskStatus.IN_PROGRESS,
active_claimant_id=company.main_pm_id,
)
return umbrella_id, batch_id
def _seed_root_subtask_in_progress(
world: World, *, umbrella_id: Any, batch_id: Any, wave: int
) -> dict[str, Any]:
"""RS1: pre-seeded mid-flight exactly like ``seed_hierarchy``'s root,
plus the ``batch_id``/``parent_task_id`` that make it a root-subtask."""
from roboco.models import Team
from roboco.models.base import TaskStatus, TaskType
stack, company = world.stack, world.company
root_id = uuid4()
root_branch = f"feature/main_pm/{str(root_id)[:8]}"
origin_branch(stack, root_branch, start="master")
seed_task(
stack,
id=root_id,
title=f"Root-subtask {wave}: greeting wave {wave}",
description=(
f"MegaTask root-subtask (wave {wave}): assembles the backend "
"cell's greeting file and lands it on this project's master via "
"the normal root→CEO chain."
),
acceptance_criteria=["the greeting feature lands on the root branch"],
task_type=TaskType.PLANNING,
team=Team.MAIN_PM,
batch_id=batch_id,
parent_task_id=umbrella_id,
project_id=world.project_id,
created_by=company.main_pm_id,
assigned_to=company.main_pm_id,
status=TaskStatus.IN_PROGRESS,
branch_name=root_branch,
active_claimant_id=company.main_pm_id,
)
return {"root_id": root_id, "root_branch": root_branch}
def _seed_root_subtask_pending(
world: World, *, umbrella_id: Any, batch_id: Any, wave: int
) -> Any:
"""RS2: a real Main-PM root-subtask, PENDING (unclaimed, no branch yet —
it is claimed for real once its dependency clears, exercising the claim
path scenarios 1-3 skip by pre-seeding roots mid-flight)."""
from roboco.models import Team
from roboco.models.base import TaskStatus, TaskType
stack, company = world.stack, world.company
root_id = uuid4()
seed_task(
stack,
id=root_id,
title=f"Root-subtask {wave}: greeting wave {wave}",
description=(
f"MegaTask root-subtask (wave {wave}): depends on the prior "
"root-subtask (the serial wave-chain edge) and repeats the same "
"root→CEO chain once the dependency clears."
),
acceptance_criteria=["the greeting feature lands on the root branch"],
task_type=TaskType.PLANNING,
team=Team.MAIN_PM,
batch_id=batch_id,
parent_task_id=umbrella_id,
project_id=world.project_id,
created_by=company.main_pm_id,
assigned_to=company.main_pm_id,
status=TaskStatus.PENDING,
)
return root_id
def _land_and_merge_cell(
stack: E2EStack,
company: Company,
project_slug: str,
h: dict[str, Any],
*,
filename: str,
) -> ScriptedAgent:
"""dev → QA → docs → cell-PM completes the child (the child-landing half
of ``test_pm_merge_chain._land_child``, parameterized by filename so two
root-subtasks sharing one project never collide on the same additive
file when both eventually merge into the same master)."""
dev_arc(
stack,
company,
project_slug,
h["child_id"],
work=(filename, f"Hello from {filename}!\n"),
)
qa_arc(stack, company, h["child_id"])
doc_arc(stack, company, h["child_id"], filename=filename)
child = task_state(stack, h["child_id"])
child_pr = stack.github.prs[child["pr_number"]]
assert child_pr["base"]["ref"] == h["cell_branch"], (
f"child PR should target the cell branch: {child_pr['base']} / {child}"
)
# The dispatcher's _dispatch_pm_review_work lane re-claims an
# awaiting_pm_review leaf for the owning cell PM before spawning it
# (_claim_task_for_agent); the complete guard requires assigned_to==PM.
dispatcher_assign(stack, h["child_id"], company.cell_pm_id)
pm = ScriptedAgent(stack, company.cell_pm_id, "be-pm", "cell_pm")
expect_ok(
pm.flow(
"complete",
task_id=str(h["child_id"]),
notes=(
"Child verified: QA passed with per-criterion verdicts and "
"docs are complete; merging the leaf PR into the cell branch."
),
),
"pm complete child",
)
assert task_state(stack, h["child_id"])["status"] == "completed"
assert origin_file(stack, h["cell_branch"], filename), (
"child merge did not land the file on the cell branch"
)
return pm
def _merge_cell_to_root(
stack: E2EStack,
company: Company,
pm: ScriptedAgent,
h: dict[str, Any],
*,
filename: str,
) -> None:
"""Dispatcher re-claim (mirrored) + the PM's cell→root merge turn (the
``test_pm_merge_chain._pm_merges_cell`` shape, parameterized by filename)."""
dispatcher_assign(stack, h["cell_id"], company.cell_pm_id)
expect_ok(
pm.flow(
"complete",
task_id=str(h["cell_id"]),
notes=(
"Gate passed; merging the assembled cell PR into the root "
"branch and closing the cell task."
),
),
"pm complete cell",
)
assert task_state(stack, h["cell_id"])["status"] == "completed"
assert origin_file(stack, h["root_branch"], filename), (
"cell merge did not land the file on the root branch"
)
def _land_root_subtask(world: World, root: dict[str, Any], *, filename: str) -> None:
"""Full cell→dev delivery under an already-claimed root-subtask."""
stack, company, project_slug = world.stack, world.company, world.project_slug
h = seed_cell_and_dev(stack, company, world.project_id, root, filename=filename)
pm = _land_and_merge_cell(stack, company, project_slug, h, filename=filename)
expect_ok(
pm.flow(
"submit_up",
task_id=str(h["cell_id"]),
notes=(
"All children terminal and merged into the cell branch; "
"assembling the cell PR for the in-path review gate."
),
),
"pm submit_up",
)
reviewer_gate_pass_arc(stack, company, h["cell_id"])
_merge_cell_to_root(stack, company, pm, h, filename=filename)
def _submit_review_escalate_merge(
stack: E2EStack, company: Company, main_pm: ScriptedAgent, root_id: Any
) -> None:
"""submit_root → reviewer gate → main_pm complete → CEO approve-and-merge.
The root analogue of ``test_root_ceo_chain``'s root→master chain, reused
verbatim for each root-subtask (each opens + merges its OWN root→master
PR — a MegaTask root-subtask behaves exactly like a plain Main-PM root
for git/CEO purposes, see ``is_batch_root_subtask``)."""
rid = str(root_id)
expect_ok(
main_pm.flow(
"submit_root",
task_id=rid,
notes=(
"Every cell task is terminal and assembled on the root "
"branch; opening the root PR against master for the gate."
),
),
"main_pm submit_root",
)
root_state = task_state(stack, root_id)
assert root_state["status"] == "awaiting_pr_review", root_state
assert root_state["pr_number"], root_state
reviewer_gate_pass_arc(stack, company, root_id)
dispatcher_assign(stack, root_id, company.main_pm_id)
expect_ok(
main_pm.flow(
"complete",
task_id=rid,
notes=(
"Gate passed on the assembled root PR; approving the "
"root-subtask and escalating to the CEO for the merge "
"decision."
),
),
"main_pm complete root-subtask",
)
assert task_state(stack, root_id)["status"] == "awaiting_ceo_approval"
resp = httpx.post(
f"{stack.base_url}/api/tasks/{rid}/approve-and-merge",
headers={"X-Agent-ID": str(company.ceo_id), "X-Agent-Role": "ceo"},
timeout=60,
)
assert resp.status_code == HTTPStatus.OK, (
f"approve-and-merge: {resp.status_code} {resp.text[:1500]}"
)
assert task_state(stack, root_id)["status"] == "completed"
def test_megatask_umbrella_sequenced_close(e2e_stack: E2EStack) -> None:
stack = e2e_stack
company = seed_company(stack)
project_id, project_slug = seed_project(stack, company)
world = World(stack, company, project_id, project_slug)
main_pm = ScriptedAgent(stack, company.main_pm_id, "main-pm", "main_pm")
umbrella_id, batch_id = _seed_umbrella(stack, company)
rs1 = _seed_root_subtask_in_progress(
world, umbrella_id=umbrella_id, batch_id=batch_id, wave=1
)
rs2_id = _seed_root_subtask_pending(
world, umbrella_id=umbrella_id, batch_id=batch_id, wave=2
)
# The serial wave-chain edge: RS2 waits for RS1, wired the same way
# production sequencing does (TaskService.add_dependency), not a status
# write.
wire_dependency(stack, rs2_id, rs1["root_id"])
# --- 1. SEQUENCING HOLD: RS2 cannot be planned while RS1 is open --------
expect_error(
main_pm.flow(
"i_will_plan",
task_id=str(rs2_id),
plan="Land wave 2 once wave 1 is terminal.",
approach=_APPROACH,
sub_tasks=[],
),
"invalid_state",
"main_pm i_will_plan RS2 while RS1 open",
)
assert task_state(stack, rs2_id)["status"] == "pending"
# --- 2. RS1: the REAL dev→QA→doc→cell→root→CEO chain to master ---------
_land_root_subtask(world, rs1, filename="rs1.txt")
_submit_review_escalate_merge(stack, company, main_pm, rs1["root_id"])
assert origin_file(stack, "master", "rs1.txt"), "RS1 did not land on master"
# --- 3. Hold clears: RS2 can now be planned (real claim + branch cut) --
rs2_branch = f"feature/main_pm/{str(rs2_id)[:8]}"
# Cut RS2's branch from the NOW-current master (post-RS1-merge) so the
# claim needs no behind-base auto-sync — mirrors how a wave-2
# root-subtask's branch is only meaningful once wave 1 has landed.
origin_branch(stack, rs2_branch, start="master")
set_branch_name(stack, rs2_id, rs2_branch)
def _plan_rs2() -> dict[str, Any]:
return main_pm.flow(
"i_will_plan",
task_id=str(rs2_id),
plan="RS1 is terminal; land wave 2 now.",
approach=_APPROACH,
sub_tasks=[
{
"title": "Assemble the backend cell",
"description": (
"Delegate the backend cell task that writes rs2.txt, "
"land it on the root branch, then submit the root PR "
"for the in-path review gate."
),
},
],
)
# Real choreography (mirrors dev_arc's claim-time note): the composed
# claim succeeds and stays; the post-claim tracing gate demands the
# claim-time journal:decision; the retry short-circuits as re-entry.
expect_error(_plan_rs2(), "tracing_gap", "main_pm i_will_plan RS2 first attempt")
expect_ok(
main_pm.do(
"note",
scope="decision",
task_id=str(rs2_id),
text=(
"Wave 1 (RS1) is completed and merged to master; wave 2 "
"(RS2) repeats the identical backend-cell delegation on its "
"own root branch against the same project."
),
),
"main_pm decision note at claim",
)
expect_ok(
_plan_rs2(),
"main_pm i_will_plan RS2 after RS1 terminal",
)
rs2_state = task_state(stack, rs2_id)
assert rs2_state["status"] == "in_progress", rs2_state
assert rs2_state["branch_name"] == rs2_branch, rs2_state
rs2 = {"root_id": rs2_id, "root_branch": rs2_branch}
_land_root_subtask(world, rs2, filename="rs2.txt")
_submit_review_escalate_merge(stack, company, main_pm, rs2_id)
assert origin_file(stack, "master", "rs2.txt"), "RS2 did not land on master"
# --- 4. UMBRELLA CLOSE: branchless complete → escalate → ceo-approve ---
expect_ok(
main_pm.flow(
"complete",
task_id=str(umbrella_id),
notes=(
"Both root-subtasks are completed and merged to master; "
"closing the MegaTask umbrella and escalating for CEO "
"sign-off."
),
),
"main_pm complete umbrella",
)
escalated = wait_for_status(stack, umbrella_id, "awaiting_ceo_approval")
assert escalated["pr_number"] is None, escalated
resp = httpx.post(
f"{stack.base_url}/api/tasks/{umbrella_id}/ceo-approve",
headers={"X-Agent-ID": str(company.ceo_id), "X-Agent-Role": "ceo"},
json={"notes": ("Both root-subtasks landed on master; MegaTask complete.")},
timeout=60,
)
assert resp.status_code == HTTPStatus.OK, (
f"ceo-approve: {resp.status_code} {resp.text[:1500]}"
)
final = wait_for_status(stack, umbrella_id, "completed")
assert final["pr_number"] is None, final
assert origin_file(stack, "master", "rs1.txt")
assert origin_file(stack, "master", "rs2.txt")