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