Files
roboco/tests/e2e_smoke/test_sequence_claim_gate.py
T
8f3f4236c0 feat(tasks): sequence is the bar — strict sibling ordering at the claim chokepoint (#452)
* feat(tasks): enforce sibling sequence order at the claim chokepoint

A task with a parent and effective sequence N (COALESCE(sequence, 0))
can no longer be claimed while any sibling with a strictly lower
effective sequence is non-terminal — assignee-blind, independent of and
stricter than dependency_ids, enforced in _validate_claim_preconditions
so both claim paths (gateway verbs and the dispatcher's raw REST claim)
cross it. Ties run parallel; cancelled siblings never block; sequence 0
and parentless tasks are unaffected. Live failure this guards: a PM
delegated revision subtasks sequenced 0..3 with no dependency edges and
seq 2 started alongside seq 0 — sequence was advisory-only.

set_sequence's contract updated accordingly. New e2e smoke case drives
the refusal and the post-completion claim through the real gateway.

* chore(scripts): skip .uv-cache and .claude in the prose scanner

Repo-local tool dirs (private uv cache, agent worktrees) carry vendored
and generated markdown that tripped make reflow-check.

* fix(tasks): wave-derived delegation sequences + claim-gate hardening

Three fixes from the adversarial review of the sequence claim gate:

Delegation no longer stamps a raw per-sibling ordinal (deterministic
merge-order bookkeeping) as sequence — under the strict gate that
serialized ALL delegated work, including fully independent cross-dev and
cross-cell siblings. Sequences are now wave-derived post-wiring
(stamp_wave_sequence: 1 + max same-parent dependency sequence, 0 when
independent), so independent siblings tie and run parallel while
colliding/ordered work ascends. The cross-cell UX wiring restamps
instead of writing relative ux+1 values (a relative write could invert
a collision-derived stamp), and the dispatch merge/lane barriers gain a
created_at tiebreak for wave-tied siblings so shared-branch merge order
stays deterministic. PM-authored sequences are never rewritten.

The guard now also fires on reclaims from needs_revision (a lower-
sequence sibling delegated after the first claim was invisible), and
tasks.parent_task_id gains an index (migration 069) — the guard's
sibling probe ran as a Seq Scan on the hottest verb.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-10 22:55:00 +02:00

144 lines
4.8 KiB
Python

"""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).
expect_error(
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,
),
"invalid_state",
"main_pm i_will_plan seq-1 while seq-0 open (no dependency edge)",
)
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