mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
449 lines
17 KiB
Python
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")
|