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>
This commit is contained in:
Renzo F
2026-07-10 22:55:00 +02:00
committed by GitHub
co-authored by Renn F
parent 7ff70ab5e2
commit 8f3f4236c0
12 changed files with 857 additions and 65 deletions
@@ -10,6 +10,7 @@ Keyed on the assignee (not the team like the merge barrier) and gates only
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
@@ -127,6 +128,27 @@ async def test_higher_sequence_same_dev_sibling_does_not_block() -> None:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_equal_sequence_same_dev_tiebreaks_by_created_at() -> None:
"""Wave ties in a dev's own lane order by created_at (mirroring the merge
barrier): the earlier-created tied sibling holds the later one; the
later-created one does not hold the earlier."""
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
task["created_at"] = "2026-07-10T12:00:00+00:00"
earlier = _sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS)
earlier.created_at = datetime(2026, 7, 10, 11, 0, tzinfo=UTC)
p1, p2 = _patch_siblings([earlier])
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is True
later = _sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS)
later.created_at = datetime(2026, 7, 10, 13, 0, tzinfo=UTC)
p1, p2 = _patch_siblings([later])
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_non_code_task_is_never_gated_without_db() -> None:
orch = _new_orchestrator()
@@ -9,6 +9,7 @@ cancelled sibling can't deadlock the rest.
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
@@ -111,6 +112,61 @@ async def test_higher_sequence_sibling_does_not_block() -> None:
assert await orch._blocked_by_earlier_sibling(task) is False
@pytest.mark.asyncio
async def test_equal_sequence_created_earlier_sibling_blocks() -> None:
"""Wave ties (independent siblings share a sequence) tie-break by
created_at: the earlier-created same-team sibling merges first, so the
later one's review dispatch is held — the shared-cell-branch merge race
the ordinal used to prevent."""
orch = _new_orchestrator()
task = {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": 0,
"team": "frontend",
"created_at": "2026-07-10T12:00:00+00:00",
}
earlier = _sibling(0, "frontend", TaskStatus.IN_PROGRESS)
earlier.created_at = datetime(2026, 7, 10, 11, 0, tzinfo=UTC)
p1, p2 = _patch_siblings([earlier])
with p1, p2:
assert await orch._blocked_by_earlier_sibling(task) is True
@pytest.mark.asyncio
async def test_equal_sequence_created_later_sibling_does_not_block() -> None:
orch = _new_orchestrator()
task = {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": 0,
"team": "frontend",
"created_at": "2026-07-10T12:00:00+00:00",
}
later = _sibling(0, "frontend", TaskStatus.IN_PROGRESS)
later.created_at = datetime(2026, 7, 10, 13, 0, tzinfo=UTC)
p1, p2 = _patch_siblings([later])
with p1, p2:
assert await orch._blocked_by_earlier_sibling(task) is False
@pytest.mark.asyncio
async def test_equal_sequence_unparseable_created_at_fails_open() -> None:
"""A tie that can't be ordered (missing/mock created_at) must not wedge
dispatch the tiebreak degrades to not-blocked."""
orch = _new_orchestrator()
task = {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": 0,
"team": "frontend",
}
tie = _sibling(0, "frontend", TaskStatus.IN_PROGRESS)
p1, p2 = _patch_siblings([tie])
with p1, p2:
assert await orch._blocked_by_earlier_sibling(task) is False
@pytest.mark.asyncio
async def test_no_parent_returns_false_without_db() -> None:
orch = _new_orchestrator()
+24 -2
View File
@@ -1352,9 +1352,12 @@ async def test_unclaimed_parent_acs_counts_live_children_not_just_completed() ->
def _svc_with_sibling_status_seq(rows: list[tuple]) -> TaskService:
"""TaskService whose execute() yields (status, sequence) sibling rows."""
"""TaskService whose execute() yields (status, sequence[, created_at])
sibling rows; 2-tuples are padded with created_at=None (only compared on
a sequence tie)."""
row_width = 3 # (status, sequence, created_at)
res = MagicMock()
res.all.return_value = rows
res.all.return_value = [r if len(r) == row_width else (*r, None) for r in rows]
return TaskService(MagicMock(execute=AsyncMock(return_value=res)))
@@ -1386,6 +1389,25 @@ async def test_earlier_incomplete_code_sibling_false_when_earlier_terminal() ->
assert await svc.has_earlier_incomplete_code_sibling(task) is False
@pytest.mark.asyncio
async def test_earlier_incomplete_code_sibling_tie_breaks_by_created_at() -> None:
# Wave ties (equal sequence) order by created_at: the earlier-created
# tied sibling holds the later one; a later-created one does not.
task = _build_task(
task_type=TaskType.CODE.value,
parent_task_id=uuid4(),
assigned_to=uuid4(),
sequence=1,
created_at=datetime(2026, 7, 10, 12, 0, tzinfo=UTC),
)
earlier = datetime(2026, 7, 10, 11, 0, tzinfo=UTC)
later = datetime(2026, 7, 10, 13, 0, tzinfo=UTC)
svc = _svc_with_sibling_status_seq([(TaskStatus.IN_PROGRESS, 1, earlier)])
assert await svc.has_earlier_incomplete_code_sibling(task) is True
svc = _svc_with_sibling_status_seq([(TaskStatus.IN_PROGRESS, 1, later)])
assert await svc.has_earlier_incomplete_code_sibling(task) is False
@pytest.mark.asyncio
async def test_earlier_incomplete_code_sibling_false_for_higher_seq_only() -> None:
# A LATER sibling (seq 3) does not hold an earlier leaf (seq 2).