2026-07-10 22:55:00 +02:00
|
|
|
"""Scenario: the sibling-sequence claim gate holds independent of edges.
|
|
|
|
|
|
|
|
|
|
CEO directive: sequence is the bar, full stop. A same-parent sibling with a
|
|
|
|
|
strictly lower sequence must hold the later sibling's claim even when NO
|
|
|
|
|
dependency edge was ever wired between them — the live bug this guardrails:
|
|
|
|
|
a PM delegated a batch of revision subtasks by sequence alone (0..3, no
|
|
|
|
|
depends_on edges), and a later sequence claimed in parallel with an earlier,
|
|
|
|
|
still-open one.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
|
|
|
|
|
|
from tests.e2e_smoke.arcs import (
|
|
|
|
|
origin_branch,
|
|
|
|
|
seed_company,
|
|
|
|
|
seed_project,
|
|
|
|
|
seed_task,
|
|
|
|
|
set_branch_name,
|
|
|
|
|
task_state,
|
|
|
|
|
)
|
|
|
|
|
from tests.e2e_smoke.harness import ScriptedAgent, expect_error
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from tests.e2e_smoke.harness import E2EStack
|
|
|
|
|
|
|
|
|
|
# Pydantic's IWillPlanRequest.approach enforces >= 150 chars, and the PM
|
|
|
|
|
# sub_tasks gate requires a real (>= 60 char) description, so both blocked
|
|
|
|
|
# and unblocked calls need compliant payloads — the sequence gate must be
|
|
|
|
|
# the thing that fires, not an earlier content gate.
|
|
|
|
|
_APPROACH = (
|
|
|
|
|
"Plan revision 1 once revision 0 reaches a terminal state. This batch "
|
|
|
|
|
"wires no dependency edges between its siblings, only sequence, so the "
|
|
|
|
|
"claim gate alone must hold the delegation order end to end."
|
|
|
|
|
)
|
|
|
|
|
_SUB_TASKS = [
|
|
|
|
|
{
|
|
|
|
|
"title": "Land revision 1",
|
|
|
|
|
"description": (
|
|
|
|
|
"Apply the second sequenced revision once revision 0 is terminal "
|
|
|
|
|
"and open its leaf PR against the cell branch."
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cancel(stack: E2EStack, task_id: Any) -> None:
|
|
|
|
|
"""Direct terminal-status write — mirrors dispatcher_assign/set_branch_name's
|
|
|
|
|
style (arcs.py): stands in for the real dev->QA->doc->PM chain reaching a
|
|
|
|
|
terminal state, out of scope for this gate-only scenario."""
|
|
|
|
|
from roboco.db.tables import TaskTable
|
|
|
|
|
from roboco.models.base import TaskStatus
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
|
|
|
|
|
async def _run(session: AsyncSession) -> None:
|
|
|
|
|
row = (
|
|
|
|
|
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
|
|
|
|
|
).scalar_one()
|
|
|
|
|
row.status = TaskStatus.CANCELLED
|
|
|
|
|
|
|
|
|
|
stack.run_db(_run)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sibling_sequence_blocks_claim_until_earlier_sibling_terminal(
|
|
|
|
|
e2e_stack: E2EStack,
|
|
|
|
|
) -> None:
|
|
|
|
|
from roboco.models import Team
|
|
|
|
|
from roboco.models.base import TaskType
|
|
|
|
|
|
|
|
|
|
stack = e2e_stack
|
|
|
|
|
company = seed_company(stack)
|
|
|
|
|
project_id, _project_slug = seed_project(stack, company)
|
|
|
|
|
main_pm = ScriptedAgent(stack, company.main_pm_id, "main-pm", "main_pm")
|
|
|
|
|
|
|
|
|
|
parent_id = seed_task(
|
|
|
|
|
stack,
|
|
|
|
|
title="Revision batch",
|
|
|
|
|
description="Coordinates a sequenced batch of revision subtasks.",
|
|
|
|
|
acceptance_criteria=["every revision lands"],
|
|
|
|
|
task_type=TaskType.PLANNING,
|
|
|
|
|
team=Team.MAIN_PM,
|
|
|
|
|
project_id=project_id,
|
|
|
|
|
created_by=company.main_pm_id,
|
|
|
|
|
assigned_to=company.main_pm_id,
|
|
|
|
|
)
|
|
|
|
|
seq0_id = seed_task(
|
|
|
|
|
stack,
|
|
|
|
|
title="Revision 0",
|
|
|
|
|
description="First sequenced revision subtask.",
|
|
|
|
|
acceptance_criteria=["revision 0 lands"],
|
|
|
|
|
task_type=TaskType.PLANNING,
|
|
|
|
|
team=Team.MAIN_PM,
|
|
|
|
|
project_id=project_id,
|
|
|
|
|
parent_task_id=parent_id,
|
|
|
|
|
sequence=0,
|
|
|
|
|
created_by=company.main_pm_id,
|
|
|
|
|
assigned_to=company.main_pm_id,
|
|
|
|
|
)
|
|
|
|
|
seq1_id = seed_task(
|
|
|
|
|
stack,
|
|
|
|
|
title="Revision 1",
|
|
|
|
|
description="Second sequenced revision subtask.",
|
|
|
|
|
acceptance_criteria=["revision 1 lands"],
|
|
|
|
|
task_type=TaskType.PLANNING,
|
|
|
|
|
team=Team.MAIN_PM,
|
|
|
|
|
project_id=project_id,
|
|
|
|
|
parent_task_id=parent_id,
|
|
|
|
|
sequence=1,
|
|
|
|
|
created_by=company.main_pm_id,
|
|
|
|
|
assigned_to=company.main_pm_id,
|
|
|
|
|
)
|
|
|
|
|
branch = f"feature/main_pm/{str(seq1_id)[:8]}"
|
|
|
|
|
origin_branch(stack, branch, start="master")
|
|
|
|
|
set_branch_name(stack, seq1_id, branch)
|
|
|
|
|
# No wire_dependency() call anywhere in this test — sequence alone must
|
|
|
|
|
# hold the order; seq0 stays PENDING (open, non-terminal).
|
|
|
|
|
|
2026-07-24 13:53:29 +02:00
|
|
|
env = expect_error(
|
2026-07-10 22:55:00 +02:00
|
|
|
main_pm.flow(
|
|
|
|
|
"i_will_plan",
|
|
|
|
|
task_id=str(seq1_id),
|
|
|
|
|
plan="Land revision 1 once revision 0 is terminal.",
|
|
|
|
|
approach=_APPROACH,
|
|
|
|
|
sub_tasks=_SUB_TASKS,
|
|
|
|
|
),
|
2026-07-24 13:53:29 +02:00
|
|
|
"sequence_held",
|
2026-07-10 22:55:00 +02:00
|
|
|
"main_pm i_will_plan seq-1 while seq-0 open (no dependency edge)",
|
|
|
|
|
)
|
2026-07-24 13:53:29 +02:00
|
|
|
assert "Revision 0" in (env.get("message") or "")
|
2026-07-10 22:55:00 +02:00
|
|
|
assert task_state(stack, seq1_id)["status"] == "pending"
|
|
|
|
|
|
|
|
|
|
_cancel(stack, seq0_id)
|
|
|
|
|
|
|
|
|
|
env = main_pm.flow(
|
|
|
|
|
"i_will_plan",
|
|
|
|
|
task_id=str(seq1_id),
|
|
|
|
|
plan="Land revision 1 now that revision 0 is terminal.",
|
|
|
|
|
approach=_APPROACH,
|
|
|
|
|
sub_tasks=_SUB_TASKS,
|
|
|
|
|
)
|
|
|
|
|
assert env.get("error") != "invalid_state", env
|
|
|
|
|
assert task_state(stack, seq1_id)["status"] == "in_progress", env
|