mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Token optimization levers — claim-scoped briefing, payload caps, role-scoped optimal, notification-spawn cooldown (#292)
* feat(gateway): claim-scoped context briefing — heavy sections only on context-acquisition verbs
* feat(gateway): cap unbounded LLM-facing payloads — embedded diffs, notification bodies, handoff journal content, north star
* feat(mcp): role-scope the optimal server's tool groups; index management becomes dev/test-only
* feat(mcp): cap per-result content on kb/error/learning search, mentor sources, rag citations
* refactor(gateway): extract heavy-briefing sections + clip helper to keep xenon ranks
* feat(orchestrator): cross-tick cooldown for notification-triggered spawns
* feat(usage,orchestrator): scope spawn-waste to anthropic sessions; cap agent Bash output via settings env
* docs: claim-scoped briefing, payload caps, optimal role-scoping, notification-spawn cooldown
* test(mcp): type the mixed-item cap fixture explicitly
* fix(orchestrator): lazy-init the notification-spawn cooldown store
* fix(lifecycle): admin-override claim reconciliation + PM request_changes verb (S6 postmortem B3+B4)
B3 — admin_set_status now reconciles claim ownership when leaving BLOCKED:
review/queue targets clear claimed_by/claimed_at/active_claimant_id and
consume the pre-block snapshot (a stale escalation claim was stranding the
next claimant: give_me_work handed the task out while note() bounced
not_authorized — the live b8fe0494 wedge). The pending/in_progress restore
path also syncs active_claimant_id, and a REST PATCH unassign releases the
claim with it.
B4 — new PM verb request_changes: awaiting_pm_review -> needs_revision with
concrete issues. The PM previously had no reject at merge review (only
complete/escalate), so an AC/scope violation looped i_am_blocked->escalate
4x live. Full vertical: lifecycle transition + ActionSpec + IntentSpec,
TaskService.request_changes (routes like a QA fail — original dev for a
leaf, revision PM for assembled; issues appended to dev_notes), verb-runner
compose, choreographer verb (spec gate + non-empty issues + soup check +
a2a delivery of the reject reason), HTTP routes on both PM flows, MCP tool,
journal:decision tracing, PM prompts, regenerated lifecycle artifacts.
* fix(panel): stop scorecard fetches for fallback-roster placeholder ids
useAgents() serves the static AGENT_ROSTER (ids "1".."22") while agent
definitions load; the Scorecards tab fetched a member scorecard per row
immediately, firing 22 guaranteed-422 requests per refetch cycle. Through
the browser's per-origin connection limit those queued every metrics-page
query behind them (~10s of skeletons on every tab). Gate the fetch on a
real member id (agent UUID or the "ceo" alias).
* Upgraded uv.lock
* fix(sequencing): declared deps become real edges + full loop-breaker coverage + assembled-branch freshness (S6 postmortem B1/B2/B6 + breaker)
B1a — code delegations REQUIRE a collision surface: new TASK_AT_DELEGATE
completeness spec (conditional FieldRequirement, when=('task_type','code'))
enforced at the gateway delegate gate. A no-surface code sibling is
'parallel to everything' by analyzer design, which is how two devs ran the
CEO's explicitly-ordered work out of order (f3e1afc5: seq#1 started before
seq#0, zero dependency edges). PM prompts updated; REST/manual creation
(TASK_AT_CREATE) unchanged.
B1b — the CEO's declared 'Depends on' lists become real edges: DraftSurface
gains declared_depends_on; SequencingService.analyze unions declared edges
(validated: self/out-of-range rejected) with the derived collision rules,
cycle-checked by the existing toposort. confirm_live_batch/preview_batch
map each draft's depends_on through (string indices coerced); intake tool
doc + prompter role prompt instruct verbatim copying. The live S6 root got
1 of its 3 declared in-batch edges and started alongside still-running R3.
Breaker coverage — the progress-aware respawn circuit breaker
(_pm_respawn_should_gate: strike counting, status-advance reset,
tracing-gap budget, DB durability, one-shot CEO notification) was consulted
by only 3 spawn paths; the doc/QA/dev/PR-review/PR-gate/revision/board
paths spawned unguarded at fixed cadence (the 26-respawn fe-doc loop,
~$7.20). Now consulted at every task-keyed spawn site (14 total).
B2 — assembled-branch freshness: submit_up/submit_root auto-sync the
assembled branch when it has fallen behind its base (children are terminal
at submit time, so the rebase is safe; master is never written). A rebase
conflict is a hard reject naming the files instead of a blind re-review —
kills the needs_revision↔awaiting_pr_review ping-pong of re-submitting a
stale head. Leaf i_am_done already had the behind-base gate; claim-time
fetch-fresh cut already existed.
B6 — documenter revision-pass loop: the awaiting_documentation bail
rejections (i_am_blocked/unclaim) now name the actual exit (i_documented
re-affirm) and the documenter prompt gets an explicit revision-pass rule.
* fix(orchestration): assembly-integrity gate + dispatcher heartbeat (incidents #11, #1)
Assembly integrity — submit_up/submit_root refuse when a completed child's
commits are not patch-present in the assembled branch (git cherry —
rebase-safe; branch pruned after merge or any git error fails open). Live
incident #11: a completed revert subtask's merge was lost from the cell
branch and the review gate re-flagged the exact violation the revert fixed,
spawning another revision cycle.
Dispatcher heartbeat — a dispatcher.alive audit row every 5 minutes from
the dispatch loop. The 2026-07-01 outage was 4h25m of fleet-wide silence
with no way to distinguish 'loop dead' from 'no work'; the loop's stdout
died with the container while audit_log survives. CHANGELOG for tonight's
full sweep included.
* style: ruff format for the orchestration sweep
* refactor(gateway): fold the assembled-submit guards + trim complexity under the xenon gate
_assembled_submit_guards combines the #11 integrity check and B2 freshen for
submit_up/submit_root; lifecycle's invalid-source remediate and git's
per-child cherry probe extracted into helpers. Test harnesses built via
__new__ stub the respawn tracker (the breaker now runs on their paths).
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""PATCH null-clear semantics — unassigning a task implies releasing its claim.
|
||||
|
||||
Live wedge (2026-07-01): a CEO PATCH set assigned_to=null on a wedged task but
|
||||
claimed_by/active_claimant_id survived, so the task kept routing to the stale
|
||||
claimant while the next agent's content writes bounced not_authorized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.api.routes.tasks import _apply_null_clears
|
||||
|
||||
|
||||
def _task(**overrides: object) -> SimpleNamespace:
|
||||
owner = uuid4()
|
||||
base: dict[str, object] = {
|
||||
"assigned_to": owner,
|
||||
"claimed_by": owner,
|
||||
"claimed_at": datetime.now(UTC),
|
||||
"active_claimant_id": owner,
|
||||
"parent_task_id": uuid4(),
|
||||
"project_id": uuid4(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def test_unassign_clears_claim_fields() -> None:
|
||||
"""assigned_to=null releases the claim triplet with it."""
|
||||
task = _task()
|
||||
_apply_null_clears(task, {"assigned_to": None})
|
||||
assert task.assigned_to is None
|
||||
assert task.claimed_by is None
|
||||
assert task.claimed_at is None
|
||||
assert task.active_claimant_id is None
|
||||
|
||||
|
||||
def test_other_null_clears_leave_claim_untouched() -> None:
|
||||
"""Clearing parent_task_id/project_id is structural — not a claim release."""
|
||||
owner = uuid4()
|
||||
task = _task(assigned_to=owner, claimed_by=owner, active_claimant_id=owner)
|
||||
_apply_null_clears(task, {"parent_task_id": None})
|
||||
assert task.parent_task_id is None
|
||||
assert task.assigned_to == owner
|
||||
assert task.claimed_by == owner
|
||||
assert task.active_claimant_id == owner
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Doc-stage bail rejections must name the real exit: i_documented.
|
||||
|
||||
Live loop (2026-07-02, b8fe0494): fe-doc respawned 26 times on a revision
|
||||
pass — the docs were already written, the agent wouldn't call i_documented
|
||||
for work it didn't author, and its bail attempts (i_am_blocked/unclaim) were
|
||||
rejected with a generic remediate that never mentioned the one verb that IS
|
||||
the exit from awaiting_documentation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.foundation.identity import Role
|
||||
from roboco.foundation.policy.lifecycle import Context, can_invoke_intent
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Task:
|
||||
status: object = "awaiting_documentation"
|
||||
assigned_to: object = None
|
||||
task_type: object = "code"
|
||||
team: object = "frontend"
|
||||
created_by: object = field(default_factory=uuid4)
|
||||
|
||||
|
||||
def test_doc_block_rejection_points_at_i_documented() -> None:
|
||||
doc = uuid4()
|
||||
task = _Task(assigned_to=doc)
|
||||
decision = can_invoke_intent(
|
||||
Role.DOCUMENTER, "i_am_blocked", task, Context(actor_id=doc)
|
||||
)
|
||||
assert not decision.allowed
|
||||
assert "i_documented" in (decision.remediate or "")
|
||||
@@ -0,0 +1,91 @@
|
||||
"""IntentSpec for the PM `request_changes` verb (S6 postmortem, gap B4).
|
||||
|
||||
At awaiting_pm_review the PM previously had NO reject verb — only `complete`
|
||||
or escalate — so a PM that caught a genuine AC/scope violation at merge review
|
||||
could only loop `i_am_blocked` + escalate (the live fe-pm block/escalate loop,
|
||||
2026-07-01). `request_changes` is the merge-level reject: awaiting_pm_review ->
|
||||
needs_revision with concrete issues, routed like a QA fail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.foundation.identity import Role
|
||||
from roboco.foundation.policy.lifecycle import (
|
||||
Context,
|
||||
Status,
|
||||
can_invoke_intent,
|
||||
intents_for_role,
|
||||
status_after,
|
||||
)
|
||||
from roboco.services.gateway.role_config import _CELL_PM_FLOW, _MAIN_PM_FLOW
|
||||
|
||||
|
||||
def test_request_changes_is_a_pm_flow_verb() -> None:
|
||||
# Declared for the PM roles, so intents_for_role propagates it into both
|
||||
# PM flows automatically — the spec is canon, no role_config edit.
|
||||
assert "request_changes" in intents_for_role(Role.CELL_PM)
|
||||
assert "request_changes" in intents_for_role(Role.MAIN_PM)
|
||||
assert "request_changes" in _CELL_PM_FLOW
|
||||
assert "request_changes" in _MAIN_PM_FLOW
|
||||
|
||||
|
||||
def test_request_changes_is_pm_only() -> None:
|
||||
for role in (
|
||||
Role.DEVELOPER,
|
||||
Role.QA,
|
||||
Role.DOCUMENTER,
|
||||
Role.PR_REVIEWER,
|
||||
Role.PRODUCT_OWNER,
|
||||
Role.HEAD_MARKETING,
|
||||
Role.AUDITOR,
|
||||
):
|
||||
assert "request_changes" not in intents_for_role(role), (
|
||||
f"{role} must not get request_changes"
|
||||
)
|
||||
|
||||
|
||||
def test_request_changes_transitions_pm_review_to_needs_revision() -> None:
|
||||
assert status_after("request_changes", Status.AWAITING_PM_REVIEW) == (
|
||||
Status.NEEDS_REVISION
|
||||
)
|
||||
|
||||
|
||||
def test_request_changes_only_from_awaiting_pm_review() -> None:
|
||||
for status in (
|
||||
Status.IN_PROGRESS,
|
||||
Status.AWAITING_QA,
|
||||
Status.AWAITING_PR_REVIEW,
|
||||
Status.BLOCKED,
|
||||
Status.COMPLETED,
|
||||
):
|
||||
assert status_after("request_changes", status) is None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Task:
|
||||
status: object = Status.AWAITING_PM_REVIEW
|
||||
assigned_to: object = None
|
||||
task_type: object = "code"
|
||||
team: object = "frontend"
|
||||
created_by: object = field(default_factory=uuid4)
|
||||
|
||||
|
||||
def test_request_changes_allowed_for_cell_pm_at_pm_review() -> None:
|
||||
pm = uuid4()
|
||||
task = _Task(assigned_to=pm)
|
||||
decision = can_invoke_intent(
|
||||
Role.CELL_PM, "request_changes", task, Context(actor_id=pm)
|
||||
)
|
||||
assert decision.allowed
|
||||
|
||||
|
||||
def test_request_changes_rejected_outside_pm_review() -> None:
|
||||
pm = uuid4()
|
||||
task = _Task(status=Status.IN_PROGRESS, assigned_to=pm)
|
||||
decision = can_invoke_intent(
|
||||
Role.CELL_PM, "request_changes", task, Context(actor_id=pm)
|
||||
)
|
||||
assert not decision.allowed
|
||||
@@ -0,0 +1,76 @@
|
||||
"""TASK_AT_DELEGATE — code delegations must declare a collision surface.
|
||||
|
||||
Live break (2026-07-02, f3e1afc5): a PM delegated two code subtasks to two
|
||||
devs with NO intends_to_touch; the sibling collision analyzer treats a
|
||||
no-surface sibling as parallel to everything, so both dispatched at once and
|
||||
seq#1 started before seq#0 against a base missing its prerequisite. The
|
||||
surface is what turns sibling ordering into real dependency edges — an empty
|
||||
one silently disables sequencing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from roboco.foundation.policy.task_completeness import (
|
||||
TASK_AT_CREATE,
|
||||
TASK_AT_DELEGATE,
|
||||
check,
|
||||
)
|
||||
from roboco.models.base import TaskType
|
||||
|
||||
|
||||
def _payload(**overrides: Any) -> SimpleNamespace:
|
||||
base: dict[str, Any] = {
|
||||
"title": "Implement endpoint",
|
||||
"description": "Add /v1/foo endpoint with passing tests please",
|
||||
"assigned_to": "be-dev-1",
|
||||
"team": "backend",
|
||||
"task_type": "code",
|
||||
"nature": "technical",
|
||||
"estimated_complexity": "medium",
|
||||
"acceptance_criteria": ["GET /v1/foo returns 200 with body"],
|
||||
"intends_to_touch": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def test_code_delegation_without_surface_is_incomplete() -> None:
|
||||
result = check(TASK_AT_DELEGATE, _payload())
|
||||
assert not result.passed
|
||||
assert "intends_to_touch" in result.missing
|
||||
assert "intends_to_touch" in result.field_hints
|
||||
|
||||
|
||||
def test_code_delegation_with_empty_surface_is_incomplete() -> None:
|
||||
result = check(TASK_AT_DELEGATE, _payload(intends_to_touch=[]))
|
||||
assert not result.passed
|
||||
assert "intends_to_touch" in result.missing
|
||||
|
||||
|
||||
def test_code_delegation_with_surface_passes() -> None:
|
||||
result = check(
|
||||
TASK_AT_DELEGATE,
|
||||
_payload(intends_to_touch=["backend/api/routers/foo.py"]),
|
||||
)
|
||||
assert result.passed
|
||||
|
||||
|
||||
def test_non_code_delegation_needs_no_surface() -> None:
|
||||
for task_type in ("research", "documentation", "design", "planning"):
|
||||
result = check(TASK_AT_DELEGATE, _payload(task_type=task_type))
|
||||
assert result.passed, f"{task_type} must not require a surface"
|
||||
|
||||
|
||||
def test_enum_task_type_is_normalized() -> None:
|
||||
result = check(TASK_AT_DELEGATE, _payload(task_type=TaskType.CODE))
|
||||
assert not result.passed
|
||||
assert "intends_to_touch" in result.missing
|
||||
|
||||
|
||||
def test_task_at_create_is_unchanged() -> None:
|
||||
# REST/manual creation keeps the old contract — no surface requirement.
|
||||
result = check(TASK_AT_CREATE, _payload())
|
||||
assert result.passed
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Behind-base auto-sync for the assembled PM submits (B2).
|
||||
|
||||
The needs_revision ↔ awaiting_pr_review ping-pong (live, 2026-07-02): a cell /
|
||||
root revision re-submitted a head whose BASE had moved (sibling cells merged),
|
||||
so the gate re-failed the same missing-work finding every cycle. Leaf devs
|
||||
have the ``_behind_base_gate`` + ``sync_branch``; the assembled submits had no
|
||||
freshness check at all. ``_freshen_assembled_branch`` closes that: at
|
||||
submit_up / submit_root time every child is terminal, so rebasing the
|
||||
assembled branch onto its base is safe — conflicts become a clean rejection
|
||||
naming the files instead of a blind re-review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
base: dict[str, Any] = {
|
||||
"task": AsyncMock(),
|
||||
"work_session": AsyncMock(),
|
||||
"git": AsyncMock(),
|
||||
"a2a": AsyncMock(),
|
||||
"journal": AsyncMock(),
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return ChoreographerDeps(**base)
|
||||
|
||||
|
||||
def _cell_task() -> MagicMock:
|
||||
return MagicMock(
|
||||
id=uuid4(),
|
||||
branch_name="feature/frontend/root--cell",
|
||||
team="frontend",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_noop_when_up_to_date() -> None:
|
||||
git = AsyncMock()
|
||||
git.is_behind_base.return_value = (0, 3)
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
||||
)
|
||||
assert env is None
|
||||
git.sync_task_branch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_rebases_when_behind_and_proceeds() -> None:
|
||||
git = AsyncMock()
|
||||
git.is_behind_base.return_value = (2, 3)
|
||||
git.sync_task_branch.return_value = {"status": "rebased", "unique_commits": 3}
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
||||
)
|
||||
assert env is None
|
||||
git.sync_task_branch.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_conflicts_reject_with_files() -> None:
|
||||
git = AsyncMock()
|
||||
git.is_behind_base.return_value = (2, 3)
|
||||
git.sync_task_branch.return_value = {
|
||||
"status": "conflicts",
|
||||
"files": ["frontend/src/lib/stats.json"],
|
||||
}
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
||||
)
|
||||
assert env is not None
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "stats.json" in body["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_fails_open_on_probe_error() -> None:
|
||||
git = AsyncMock()
|
||||
git.is_behind_base.side_effect = RuntimeError("network sad")
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
||||
)
|
||||
assert env is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_fails_open_on_sync_error() -> None:
|
||||
git = AsyncMock()
|
||||
git.is_behind_base.return_value = (1, 1)
|
||||
git.sync_task_branch.side_effect = RuntimeError("rebase runner sad")
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="feature/main_pm/root", verb="submit_up"
|
||||
)
|
||||
assert env is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshen_skips_branchless_and_missing_base() -> None:
|
||||
git = AsyncMock()
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
branchless = MagicMock(id=uuid4(), branch_name=None, team="frontend")
|
||||
assert (
|
||||
await c._freshen_assembled_branch(branchless, base_branch="x", verb="submit_up")
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
await c._freshen_assembled_branch(
|
||||
_cell_task(), base_branch="", verb="submit_up"
|
||||
)
|
||||
is None
|
||||
)
|
||||
git.is_behind_base.assert_not_awaited()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Assembly integrity at submit_up — every completed child's work must be in
|
||||
the assembled branch (incident #11).
|
||||
|
||||
Live break (2026-07-02, S6 cell PR #183): revert subtask 3b9cc162 COMPLETED,
|
||||
but its commit never landed on the assembled cell branch — the reviewer
|
||||
re-flagged the exact violation the revert fixed, spawning another revision
|
||||
cycle. The gate verifies patch-equivalence (rebase-safe) per completed child
|
||||
and refuses the submit naming the children whose work is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
base: dict[str, Any] = {
|
||||
"task": AsyncMock(),
|
||||
"work_session": AsyncMock(),
|
||||
"git": AsyncMock(),
|
||||
"a2a": AsyncMock(),
|
||||
"journal": AsyncMock(),
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return ChoreographerDeps(**base)
|
||||
|
||||
|
||||
def _cell_task() -> MagicMock:
|
||||
return MagicMock(
|
||||
id=uuid4(), branch_name="feature/frontend/root--cell", team="frontend"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integrity_passes_when_all_children_merged() -> None:
|
||||
git = AsyncMock()
|
||||
git.unmerged_child_commits.return_value = []
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._assembly_integrity_guard(_cell_task(), verb="submit_up")
|
||||
assert env is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integrity_rejects_naming_missing_children() -> None:
|
||||
git = AsyncMock()
|
||||
git.unmerged_child_commits.return_value = [
|
||||
{
|
||||
"task_id": "3b9cc162",
|
||||
"title": "Revert stats.json artifact",
|
||||
"unmerged": 1,
|
||||
}
|
||||
]
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._assembly_integrity_guard(_cell_task(), verb="submit_up")
|
||||
assert env is not None
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "Revert stats.json artifact" in body["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integrity_fails_open_on_git_error() -> None:
|
||||
git = AsyncMock()
|
||||
git.unmerged_child_commits.side_effect = RuntimeError("git sad")
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
env = await c._assembly_integrity_guard(_cell_task(), verb="submit_up")
|
||||
assert env is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integrity_skips_branchless_task() -> None:
|
||||
git = AsyncMock()
|
||||
c = Choreographer(_make_deps(git=git))
|
||||
t = MagicMock(id=uuid4(), branch_name=None)
|
||||
env = await c._assembly_integrity_guard(t, verb="submit_up")
|
||||
assert env is None
|
||||
git.unmerged_child_commits.assert_not_awaited()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Claim-scoped context briefing — `_briefing_for(full=...)`.
|
||||
|
||||
Only context-acquisition verbs (give_me_work / claim / plan / resume / triage)
|
||||
carry the heavy, verb-invariant sections (company_goals, recent_team_activity,
|
||||
blockers_in_my_lane, task_handoff, institutional_memory). Every other verb gets
|
||||
the slim signals-only briefing, and the heavy repo queries are not even issued.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer
|
||||
from roboco.services.gateway.evidence_builder import (
|
||||
EVIDENCE_DIFF_CAP_CHARS,
|
||||
build_evidence_for_task,
|
||||
truncate_diff,
|
||||
)
|
||||
|
||||
_PR_NUMBER = 8
|
||||
|
||||
|
||||
def _choreographer_with_repo() -> tuple[Choreographer, AsyncMock]:
|
||||
repo = AsyncMock()
|
||||
repo.list_unread_a2a.return_value = [{"conversation_id": "c1", "unread": 2}]
|
||||
repo.list_unread_mentions.return_value = []
|
||||
repo.list_pending_notifications.return_value = [{"notification_id": "n1"}]
|
||||
repo.task_metadata_gaps.return_value = []
|
||||
repo.recent_team_activity.return_value = [{"task_id": "t1", "status": "pending"}]
|
||||
repo.blockers_in_lane.return_value = [{"task_id": "b1"}]
|
||||
repo.company_goals.return_value = {"north_star": "win"}
|
||||
repo.journal_highlights_for_task.return_value = []
|
||||
choreo = object.__new__(Choreographer)
|
||||
choreo._deps = MagicMock(evidence_repo=repo)
|
||||
return choreo, repo
|
||||
|
||||
|
||||
class TestBriefingScope:
|
||||
@pytest.mark.asyncio
|
||||
async def test_slim_default_carries_signals_only(self) -> None:
|
||||
choreo, repo = _choreographer_with_repo()
|
||||
briefing = await choreo._briefing_for(uuid4(), None)
|
||||
assert briefing["unread_a2a"] == [{"conversation_id": "c1", "unread": 2}]
|
||||
assert briefing["pending_notifications"] == [{"notification_id": "n1"}]
|
||||
for heavy in (
|
||||
"company_goals",
|
||||
"recent_team_activity",
|
||||
"blockers_in_my_lane",
|
||||
"task_handoff",
|
||||
"institutional_memory",
|
||||
):
|
||||
assert heavy not in briefing
|
||||
# The heavy queries are not even issued on the slim path.
|
||||
repo.recent_team_activity.assert_not_awaited()
|
||||
repo.blockers_in_lane.assert_not_awaited()
|
||||
repo.company_goals.assert_not_awaited()
|
||||
repo.journal_highlights_for_task.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_carries_heavy_sections(self) -> None:
|
||||
choreo, repo = _choreographer_with_repo()
|
||||
briefing = await choreo._briefing_for(uuid4(), None, full=True)
|
||||
assert briefing["company_goals"] == {"north_star": "win"}
|
||||
assert briefing["recent_team_activity"] == [
|
||||
{"task_id": "t1", "status": "pending"}
|
||||
]
|
||||
assert briefing["blockers_in_my_lane"] == [{"task_id": "b1"}]
|
||||
repo.company_goals.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_with_task_builds_handoff(self) -> None:
|
||||
choreo, repo = _choreographer_with_repo()
|
||||
task_id = uuid4()
|
||||
task = MagicMock(
|
||||
pr_number=_PR_NUMBER,
|
||||
pr_url="https://github.com/x/y/pull/8",
|
||||
branch_name="feature/backend/abc",
|
||||
commits=[{"sha": "abc123", "message": "feat: x"}],
|
||||
quick_context=None,
|
||||
)
|
||||
briefing = await choreo._briefing_for(uuid4(), task_id, task=task, full=True)
|
||||
assert briefing["task_handoff"]["pr_number"] == _PR_NUMBER
|
||||
repo.journal_highlights_for_task.assert_awaited_once_with(task_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slim_with_task_omits_handoff(self) -> None:
|
||||
choreo, repo = _choreographer_with_repo()
|
||||
task = MagicMock(pr_number=8, commits=[{"sha": "abc123"}])
|
||||
briefing = await choreo._briefing_for(uuid4(), uuid4(), task=task)
|
||||
assert "task_handoff" not in briefing
|
||||
repo.journal_highlights_for_task.assert_not_awaited()
|
||||
|
||||
|
||||
class TestPayloadCaps:
|
||||
def test_truncate_diff_caps_and_annotates(self) -> None:
|
||||
big = "x" * (EVIDENCE_DIFF_CAP_CHARS + 5_000)
|
||||
capped = truncate_diff(big)
|
||||
assert capped is not None
|
||||
assert len(capped) < len(big)
|
||||
assert capped.startswith("x" * 100) # head preserved
|
||||
assert "diff truncated" in capped
|
||||
|
||||
def test_truncate_diff_passes_small_and_none(self) -> None:
|
||||
assert truncate_diff("small diff") == "small diff"
|
||||
assert truncate_diff(None) is None
|
||||
|
||||
def test_build_evidence_caps_the_diff(self) -> None:
|
||||
task = MagicMock(
|
||||
pr_number=None,
|
||||
pr_url=None,
|
||||
commits=[],
|
||||
dev_notes=None,
|
||||
acceptance_criteria_status=[],
|
||||
)
|
||||
ev = build_evidence_for_task(
|
||||
task,
|
||||
journal_highlights=[],
|
||||
files_changed=[],
|
||||
pr_diff_summary="y" * (EVIDENCE_DIFF_CAP_CHARS * 2),
|
||||
)
|
||||
assert ev.pr_diff_summary is not None
|
||||
assert "diff truncated" in ev.pr_diff_summary
|
||||
@@ -65,6 +65,7 @@ def _delegate_inputs() -> DelegateInputs:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -570,6 +570,7 @@ async def test_delegate_parent_not_found() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -607,6 +608,7 @@ async def test_delegate_unknown_role_rejected() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -653,6 +655,7 @@ async def test_delegate_parent_no_project_rejected() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
@@ -694,6 +694,7 @@ async def test_delegate_main_pm_to_cell_pm_creates_subtask() -> None:
|
||||
task_type="planning",
|
||||
nature="technical",
|
||||
acceptance_criteria=["all backend subtasks defined with criteria"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
assert env.error is None
|
||||
@@ -736,6 +737,7 @@ async def test_delegate_cell_pm_to_team_dev_creates_subtask() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
assert env.error is None
|
||||
@@ -771,6 +773,7 @@ async def test_delegate_main_pm_to_dev_is_rejected() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -807,6 +810,7 @@ async def test_delegate_cell_pm_to_other_pm_rejected() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -842,6 +846,7 @@ async def test_delegate_unknown_assignee_returns_invalid_state() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -877,6 +882,7 @@ async def test_delegate_invalid_team_enum_rejected() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
assert env.as_dict()["error"] == "invalid_state"
|
||||
@@ -1154,6 +1160,7 @@ async def test_delegate_main_pm_to_cell_pm_rejects_code_typed_subtask() -> None:
|
||||
task_type="code", # WRONG — Cell PM should get planning
|
||||
nature="technical",
|
||||
acceptance_criteria=["all subtasks created with criteria"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
@@ -1195,6 +1202,7 @@ async def test_delegate_main_pm_to_cell_pm_accepts_planning_subtask() -> None:
|
||||
task_type="planning",
|
||||
nature="technical",
|
||||
acceptance_criteria=["all subtasks created with criteria"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
assert env.error is None
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Choreographer.request_changes — the PM merge-level reject (S6 gap B4).
|
||||
|
||||
At awaiting_pm_review the PM previously had only complete/escalate, so an AC
|
||||
violation caught at merge review looped i_am_blocked→escalate. request_changes
|
||||
routes it to needs_revision with concrete issues and a2a-delivers the reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
def _make_deps(**overrides: Any) -> ChoreographerDeps:
|
||||
base = {
|
||||
"task": AsyncMock(),
|
||||
"work_session": AsyncMock(),
|
||||
"git": AsyncMock(),
|
||||
"a2a": AsyncMock(),
|
||||
"journal": AsyncMock(),
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
base.update(overrides)
|
||||
repo = base["evidence_repo"]
|
||||
for method in (
|
||||
"list_unread_a2a",
|
||||
"list_unread_mentions",
|
||||
"list_pending_notifications",
|
||||
"task_metadata_gaps",
|
||||
"recent_team_activity",
|
||||
"blockers_in_lane",
|
||||
"journal_highlights_for_task",
|
||||
):
|
||||
getattr(repo, method).return_value = []
|
||||
_ldef = base["journal"].latest_decision_at.return_value
|
||||
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
|
||||
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
|
||||
return ChoreographerDeps(**base)
|
||||
|
||||
|
||||
def _pm_review_task(task_id: Any, assigned_to: Any) -> MagicMock:
|
||||
return MagicMock(
|
||||
id=task_id,
|
||||
status="awaiting_pm_review",
|
||||
assigned_to=assigned_to,
|
||||
team="frontend",
|
||||
task_type="code",
|
||||
pr_number=176,
|
||||
branch_name="feature/frontend/abc--def--ghi",
|
||||
orchestration_markers=None,
|
||||
)
|
||||
|
||||
|
||||
def _pm_agent_mock(pm_id: Any, role: str = "cell_pm") -> MagicMock:
|
||||
agent = MagicMock(id=pm_id, team="frontend", slug="fe-pm")
|
||||
agent.role = role
|
||||
return agent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_succeeds_and_notifies_new_owner() -> None:
|
||||
pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
dev_id = uuid4()
|
||||
t = _pm_review_task(task_id, pm_id)
|
||||
after = MagicMock(
|
||||
id=task_id,
|
||||
status="needs_revision",
|
||||
assigned_to=dev_id,
|
||||
team="frontend",
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = _pm_agent_mock(pm_id)
|
||||
task_svc.request_changes.return_value = after
|
||||
task_svc.session = MagicMock()
|
||||
task_svc.session.begin_nested = MagicMock(
|
||||
return_value=MagicMock(
|
||||
__aenter__=AsyncMock(return_value=None),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
a2a_svc = AsyncMock()
|
||||
deps = _make_deps(task=task_svc, a2a=a2a_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
issues = [
|
||||
"frontend/CLAUDE.md modified out of scope — revert the doc commit hunk",
|
||||
]
|
||||
env = await c.request_changes(pm_id, task_id, issues)
|
||||
assert env.error is None
|
||||
assert env.status == "needs_revision"
|
||||
task_svc.request_changes.assert_awaited_once()
|
||||
a2a_svc.send.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_requires_at_least_one_issue() -> None:
|
||||
pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t = _pm_review_task(task_id, pm_id)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = _pm_agent_mock(pm_id)
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.request_changes(pm_id, task_id, issues=[])
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert "issue" in body["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_rejected_outside_pm_review() -> None:
|
||||
pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t = _pm_review_task(task_id, pm_id)
|
||||
t.status = "in_progress"
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = _pm_agent_mock(pm_id)
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.request_changes(pm_id, task_id, issues=["real issue here"])
|
||||
body = env.as_dict()
|
||||
assert body["error"] is not None
|
||||
task_svc.request_changes.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_rejected_for_non_pm_role() -> None:
|
||||
dev_id = uuid4()
|
||||
task_id = uuid4()
|
||||
t = _pm_review_task(task_id, dev_id)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = t
|
||||
task_svc.agent_for.return_value = _pm_agent_mock(dev_id, role="developer")
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.request_changes(dev_id, task_id, issues=["real issue here"])
|
||||
body = env.as_dict()
|
||||
assert body["error"] is not None
|
||||
task_svc.request_changes.assert_not_awaited()
|
||||
@@ -225,6 +225,7 @@ async def test_delegate_passes_when_payload_complete() -> None:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
),
|
||||
)
|
||||
assert env.error is None, env.as_dict()
|
||||
@@ -232,3 +233,71 @@ async def test_delegate_passes_when_payload_complete() -> None:
|
||||
# Verify nature threaded through to TaskCreateRequest.
|
||||
req = task_svc.create_subtask.call_args.args[0]
|
||||
assert str(req.nature) == "technical" or req.nature.value == "technical"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_code_without_collision_surface_is_incomplete() -> None:
|
||||
"""A code delegation with NO intends_to_touch is rejected at the boundary.
|
||||
|
||||
Live break (f3e1afc5, 2026-07-02): two code siblings delegated to two devs
|
||||
with no surface — the collision analyzer treats a no-surface sibling as
|
||||
parallel to everything, so the declared sequence was decorative and seq#1
|
||||
started before seq#0 on a base missing its prerequisite.
|
||||
"""
|
||||
pm_id = uuid4()
|
||||
parent = _parent_in_progress(pm_id)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = parent
|
||||
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
|
||||
task_svc.get_subtasks.return_value = []
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.delegate(
|
||||
pm_id,
|
||||
parent.id,
|
||||
DelegateInputs(
|
||||
title="Implement endpoint",
|
||||
description="Add /v1/foo endpoint with passing tests please",
|
||||
assigned_to="be-dev-1",
|
||||
team="backend",
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
),
|
||||
)
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "incomplete_input"
|
||||
assert "intends_to_touch" in body.get("missing", [])
|
||||
task_svc.create_subtask.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_non_code_needs_no_collision_surface() -> None:
|
||||
"""Research/design/documentation delegations stay surface-free."""
|
||||
pm_id = uuid4()
|
||||
parent = _parent_in_progress(pm_id)
|
||||
new_task = MagicMock(id=uuid4())
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.return_value = parent
|
||||
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
|
||||
task_svc.get_subtasks.return_value = []
|
||||
task_svc.create_subtask.return_value = new_task
|
||||
deps = _make_deps(task=task_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.delegate(
|
||||
pm_id,
|
||||
parent.id,
|
||||
DelegateInputs(
|
||||
title="Research retry semantics",
|
||||
description="Survey retry/backoff libraries and summarize tradeoffs",
|
||||
assigned_to="be-dev-1",
|
||||
team="backend",
|
||||
task_type="research",
|
||||
nature="technical",
|
||||
acceptance_criteria=["Summary doc lists at least 3 options"],
|
||||
),
|
||||
)
|
||||
assert env.error is None, env.as_dict()
|
||||
task_svc.create_subtask.assert_awaited_once()
|
||||
|
||||
@@ -74,6 +74,7 @@ def _inputs() -> DelegateInputs:
|
||||
task_type="code",
|
||||
nature="technical",
|
||||
acceptance_criteria=["GET /v1/foo returns 200 with body"],
|
||||
intends_to_touch=["backend/api/routers/foo.py"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ def _inputs(**kw: Any) -> DelegateInputs:
|
||||
"task_type": "code",
|
||||
"nature": "technical",
|
||||
"acceptance_criteria": ["GET /v1/foo returns 200 with body"],
|
||||
"intends_to_touch": ["backend/api/routers/foo.py"],
|
||||
}
|
||||
base.update(kw)
|
||||
return DelegateInputs(**base)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""roboco_git_diff caps oversized diff text at the MCP boundary.
|
||||
|
||||
The HTTP route stays uncapped (the panel diff viewer reads it whole); the
|
||||
truncation happens only on the agent-facing tool result so a huge diff can't
|
||||
flood the session context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import types
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def git_module(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000042")
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
|
||||
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
|
||||
import roboco.mcp.git_readonly as srv
|
||||
|
||||
importlib.reload(srv)
|
||||
return srv
|
||||
|
||||
|
||||
def test_cap_diff_truncates_and_annotates(git_module: types.ModuleType) -> None:
|
||||
big = git_module._cap_diff({"diff": "z" * (git_module._DIFF_CAP_CHARS + 100)})
|
||||
assert big["diff_truncated"] is True
|
||||
assert "diff truncated" in big["diff"]
|
||||
assert len(big["diff"]) < git_module._DIFF_CAP_CHARS + 300
|
||||
|
||||
|
||||
def test_cap_diff_passes_small_untouched(git_module: types.ModuleType) -> None:
|
||||
small = git_module._cap_diff({"diff": "tiny"})
|
||||
assert small["diff"] == "tiny"
|
||||
assert "diff_truncated" not in small
|
||||
missing = git_module._cap_diff({"files_changed": 0})
|
||||
assert "diff_truncated" not in missing
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Optimal MCP server registers role-scoped tool groups.
|
||||
|
||||
Every registered schema rides in each turn's context, so a role carries only
|
||||
the groups its duties use; unknown roles fail open to the full set (minus the
|
||||
destructive index-management group, which is dev/test-only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from roboco.mcp.optimal_server import (
|
||||
_RESULT_CONTENT_CAP,
|
||||
_cap_result_content,
|
||||
create_optimal_mcp_server,
|
||||
)
|
||||
|
||||
|
||||
async def _tool_names(role: str, monkeypatch: pytest.MonkeyPatch) -> set[str]:
|
||||
monkeypatch.delenv("ROBOCO_ALLOW_FULL_TOOLSET", raising=False)
|
||||
if role:
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", role)
|
||||
else:
|
||||
monkeypatch.delenv("ROBOCO_AGENT_ROLE", raising=False)
|
||||
server = create_optimal_mcp_server("00000000-0000-0000-0000-000000000042")
|
||||
return {t.name for t in await server.list_tools()}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_developer_scope(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
names = await _tool_names("developer", monkeypatch)
|
||||
# Universal + dev-duty groups present.
|
||||
assert "roboco_kb_search" in names
|
||||
assert "roboco_ask_mentor" in names
|
||||
assert "roboco_search_error" in names
|
||||
assert "roboco_review_code" in names
|
||||
# PM/board decision tools, indexing and destructive admin absent.
|
||||
assert "roboco_record_decision" not in names
|
||||
assert "roboco_kb_index_code" not in names
|
||||
assert "roboco_reindex_all" not in names
|
||||
assert "roboco_clear_index" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pm_scope_carries_decisions_not_error_tools(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
names = await _tool_names("cell_pm", monkeypatch)
|
||||
assert "roboco_record_decision" in names
|
||||
assert "roboco_search_error" not in names
|
||||
assert "roboco_review_code" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_documenter_carries_indexing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
names = await _tool_names("documenter", monkeypatch)
|
||||
assert "roboco_kb_index_docs" in names
|
||||
assert "roboco_get_standards" in names
|
||||
assert "roboco_record_decision" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_role_fails_open_except_admin(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
names = await _tool_names("", monkeypatch)
|
||||
assert "roboco_search_error" in names
|
||||
assert "roboco_record_decision" in names
|
||||
# Destructive index management never registers without the escape hatch.
|
||||
assert "roboco_reindex_all" not in names
|
||||
|
||||
|
||||
_ITEM_LIMIT = 2
|
||||
|
||||
|
||||
def test_cap_result_content_caps_text_and_count() -> None:
|
||||
items: list[Any] = [
|
||||
{"content": "x" * (_RESULT_CONTENT_CAP + 200), "source": "a"},
|
||||
{"content": "short", "source": "b"},
|
||||
"bare-string-item",
|
||||
]
|
||||
capped = _cap_result_content(items, limit=_ITEM_LIMIT)
|
||||
assert len(capped) == _ITEM_LIMIT
|
||||
assert len(capped[0]["content"]) == _RESULT_CONTENT_CAP + 1 # + ellipsis
|
||||
assert capped[0]["content"].endswith("…")
|
||||
assert capped[1]["content"] == "short"
|
||||
# Original items are not mutated.
|
||||
assert len(items[0]["content"]) == _RESULT_CONTENT_CAP + 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_toolset_escape_hatch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
|
||||
monkeypatch.setenv("ROBOCO_ALLOW_FULL_TOOLSET", "1")
|
||||
server = create_optimal_mcp_server("00000000-0000-0000-0000-000000000042")
|
||||
names = {t.name for t in await server.list_tools()}
|
||||
assert "roboco_reindex_all" in names
|
||||
assert "roboco_record_decision" in names
|
||||
@@ -42,6 +42,20 @@ _WRITER_ROLES = ("developer", "documenter", "product_owner", "head_marketing")
|
||||
_NON_WRITER_ROLES = ("qa", "cell_pm", "main_pm", "auditor")
|
||||
|
||||
|
||||
def test_generated_settings_cap_bash_output() -> None:
|
||||
"""Agent settings carry an explicit Bash-output cap — a gate/test dump
|
||||
enters context once and is re-read at cache-read price every later turn."""
|
||||
orch = _orch()
|
||||
path = orch._generate_agent_settings(
|
||||
agent_id="be-dev-1",
|
||||
role="developer",
|
||||
workspace_path=_WS,
|
||||
cell_workspace_path=_CELL,
|
||||
)
|
||||
settings = json.loads(Path(path).read_text())
|
||||
assert settings["env"]["BASH_MAX_OUTPUT_LENGTH"] == "20000"
|
||||
|
||||
|
||||
def test_generated_settings_base_deny_has_no_global_write_edit() -> None:
|
||||
"""The settings file a developer is spawned with must NOT globally
|
||||
deny Write/Edit (that shadowed the workspace allow → unusable)."""
|
||||
|
||||
@@ -28,6 +28,8 @@ from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
def _make_orch() -> AgentOrchestrator:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
cast("Any", orch)._pm_respawn_tracker = {}
|
||||
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
|
||||
orch._instances = {}
|
||||
orch._board_dispatched = set()
|
||||
orch._board_review_ceo_notified = set()
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Dispatcher heartbeat — a silently-dead dispatch loop must be detectable.
|
||||
|
||||
Live outage (2026-07-01): zero spawns fleet-wide for 4h25m; the old
|
||||
orchestrator's dispatch loop died with no log line, no audit row, nothing —
|
||||
the deploy's restart is what fixed it, and the cause is unrecoverable. A
|
||||
periodic ``dispatcher.alive`` audit row makes "loop dead" distinguishable
|
||||
from "no work" straight from the DB (and gives the panel a staleness signal).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4 # noqa: F401 - parity with sibling harnesses
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
_TWO_HEARTBEATS = 2
|
||||
|
||||
|
||||
def _orch() -> AgentOrchestrator:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
o = cast("Any", orch)
|
||||
o._last_dispatch_heartbeat = None
|
||||
o._fire_audit = MagicMock()
|
||||
return orch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_tick_emits_heartbeat() -> None:
|
||||
orch = _orch()
|
||||
await orch._emit_dispatcher_heartbeat()
|
||||
cast("Any", orch)._fire_audit.assert_called_once()
|
||||
kwargs = cast("Any", orch)._fire_audit.call_args.kwargs
|
||||
assert kwargs["event_type"] == "dispatcher.alive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_throttled_within_window() -> None:
|
||||
orch = _orch()
|
||||
await orch._emit_dispatcher_heartbeat()
|
||||
await orch._emit_dispatcher_heartbeat()
|
||||
assert cast("Any", orch)._fire_audit.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_re_emits_after_window() -> None:
|
||||
orch = _orch()
|
||||
await orch._emit_dispatcher_heartbeat()
|
||||
cast("Any", orch)._last_dispatch_heartbeat = datetime.now(UTC) - timedelta(
|
||||
seconds=400
|
||||
)
|
||||
await orch._emit_dispatcher_heartbeat()
|
||||
assert cast("Any", orch)._fire_audit.call_count == _TWO_HEARTBEATS
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Cross-tick cooldown for notification-triggered spawns.
|
||||
|
||||
Escalation/approval/audit/a2a dispatchers carry no task_id, so neither the
|
||||
readiness gate nor the PM respawn breaker sees them — the cooldown is the
|
||||
loop-breaker that stops an unacknowledged notification from respawning its
|
||||
recipient every dispatch tick.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _orch() -> AgentOrchestrator:
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch._notification_spawn_at = {}
|
||||
return orch
|
||||
|
||||
|
||||
def test_first_spawn_allowed_then_damped() -> None:
|
||||
orch = _orch()
|
||||
with patch.object(settings, "notification_spawn_cooldown_seconds", 600):
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is False
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is True
|
||||
# A different notification or agent is independent.
|
||||
assert orch._notification_spawn_cooled("be-pm", "n2") is False
|
||||
assert orch._notification_spawn_cooled("fe-pm", "n1") is False
|
||||
|
||||
|
||||
def test_cooldown_expires() -> None:
|
||||
orch = _orch()
|
||||
with (
|
||||
patch.object(settings, "notification_spawn_cooldown_seconds", 600),
|
||||
patch("roboco.runtime.orchestrator.time.monotonic") as clock,
|
||||
):
|
||||
clock.return_value = 1_000.0
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is False
|
||||
clock.return_value = 1_300.0 # inside the window
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is True
|
||||
clock.return_value = 1_700.0 # window elapsed → retry allowed
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is False
|
||||
|
||||
|
||||
def test_zero_cooldown_disables_damper() -> None:
|
||||
orch = _orch()
|
||||
with patch.object(settings, "notification_spawn_cooldown_seconds", 0):
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is False
|
||||
assert orch._notification_spawn_cooled("be-pm", "n1") is False
|
||||
|
||||
|
||||
def test_missing_notification_id_never_damped() -> None:
|
||||
orch = _orch()
|
||||
with patch.object(settings, "notification_spawn_cooldown_seconds", 600):
|
||||
assert orch._notification_spawn_cooled("be-pm", None) is False
|
||||
assert orch._notification_spawn_cooled("be-pm", None) is False
|
||||
assert orch._notification_spawn_at == {}
|
||||
|
||||
|
||||
def test_map_prunes_expired_entries() -> None:
|
||||
orch = _orch()
|
||||
prune_at = AgentOrchestrator._NOTIFICATION_COOLDOWN_PRUNE_AT
|
||||
with (
|
||||
patch.object(settings, "notification_spawn_cooldown_seconds", 600),
|
||||
patch("roboco.runtime.orchestrator.time.monotonic") as clock,
|
||||
):
|
||||
clock.return_value = 1_000.0
|
||||
for i in range(prune_at + 1):
|
||||
orch._notification_spawn_cooled("be-pm", f"n{i}")
|
||||
assert len(orch._notification_spawn_at) > prune_at
|
||||
# All entries expired → the next insert prunes them down to ~the
|
||||
# fresh entry (plus at most the just-stamped one).
|
||||
_max_after_prune = 2
|
||||
clock.return_value = 2_000.0
|
||||
orch._notification_spawn_cooled("be-pm", "fresh")
|
||||
assert len(orch._notification_spawn_at) <= _max_after_prune
|
||||
@@ -20,7 +20,10 @@ from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _new_orchestrator() -> AgentOrchestrator:
|
||||
return AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
cast("Any", orch)._pm_respawn_tracker = {}
|
||||
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
|
||||
return orch
|
||||
|
||||
|
||||
def _sibling(
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""The respawn circuit breaker guards EVERY task-keyed spawn path.
|
||||
|
||||
Live break (2026-07-02, b8fe0494): fe-doc respawned 26 times in ~100 min
|
||||
(~$7.20) on an awaiting_documentation task with no valid verb — because
|
||||
``_pm_respawn_should_gate`` (progress-aware strikes, DB-durable, one-shot CEO
|
||||
notification) was consulted by only 3 of the ~10 task-keyed dispatch paths.
|
||||
The doc/QA/PR-gate/dev paths spawned unguarded at fixed cadence.
|
||||
|
||||
These tests pin the gate consultation on the previously-unguarded helpers:
|
||||
gate says skip → no spawn; gate says go → spawn proceeds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
def _orch(gate_result: bool) -> AgentOrchestrator:
|
||||
"""Orchestrator via __new__ with the gate + spawn stubbed."""
|
||||
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
o = cast("Any", orch)
|
||||
o._pm_respawn_should_gate = AsyncMock(return_value=gate_result)
|
||||
o.spawn_agent = AsyncMock()
|
||||
o._resolve_agent_slug = lambda x: x
|
||||
o._is_agent_active = lambda _slug: False
|
||||
o._task_git_context = lambda _t: None
|
||||
o._build_doc_prompt = lambda _t: "doc prompt"
|
||||
o._build_qa_prompt = lambda _t: "qa prompt"
|
||||
o._build_pr_gate_prompt = lambda _t: "gate prompt"
|
||||
o._select_agent_for_cell = lambda _team, _role: "fe-pr-reviewer"
|
||||
o._is_task_handled_this_tick = lambda _tid: False
|
||||
return orch
|
||||
|
||||
|
||||
def _task(**overrides: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"id": str(uuid4()),
|
||||
"status": "awaiting_documentation",
|
||||
"team": "frontend",
|
||||
"assigned_to": "fe-doc",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_respawn_consults_gate_and_skips_when_tripped() -> None:
|
||||
# The exact fe-doc loop path: assigned documenter, inactive, respawned
|
||||
# every tick. With the gate tripped, the spawn must be skipped.
|
||||
orch = _orch(gate_result=True)
|
||||
handled = await orch._respawn_doc_if_assigned(_task())
|
||||
assert handled is True # task stays handled (no auto-assign fallthrough)
|
||||
cast("Any", orch).spawn_agent.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doc_respawn_spawns_when_gate_clear() -> None:
|
||||
orch = _orch(gate_result=False)
|
||||
handled = await orch._respawn_doc_if_assigned(_task())
|
||||
assert handled is True
|
||||
cast("Any", orch).spawn_agent.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assigned_qa_consults_gate_and_skips_when_tripped() -> None:
|
||||
orch = _orch(gate_result=True)
|
||||
handled = await orch._spawn_assigned_qa(
|
||||
_task(status="awaiting_qa"), assigned_to="fe-qa"
|
||||
)
|
||||
assert handled is True
|
||||
cast("Any", orch).spawn_agent.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assigned_qa_spawns_when_gate_clear() -> None:
|
||||
orch = _orch(gate_result=False)
|
||||
handled = await orch._spawn_assigned_qa(
|
||||
_task(status="awaiting_qa"), assigned_to="fe-qa"
|
||||
)
|
||||
assert handled is True
|
||||
cast("Any", orch).spawn_agent.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pr_gate_dispatch_consults_gate_and_skips_when_tripped() -> None:
|
||||
orch = _orch(gate_result=True)
|
||||
o = cast("Any", orch)
|
||||
o._fetch_tasks = AsyncMock(
|
||||
return_value=[_task(status="awaiting_pr_review", team="frontend")]
|
||||
)
|
||||
await orch._dispatch_pr_gate_work(MagicMock())
|
||||
o.spawn_agent.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pr_gate_dispatch_spawns_when_gate_clear() -> None:
|
||||
orch = _orch(gate_result=False)
|
||||
o = cast("Any", orch)
|
||||
o._fetch_tasks = AsyncMock(
|
||||
return_value=[_task(status="awaiting_pr_review", team="frontend")]
|
||||
)
|
||||
await orch._dispatch_pr_gate_work(MagicMock())
|
||||
o.spawn_agent.assert_awaited_once()
|
||||
@@ -8,7 +8,7 @@ forever. This re-spawns its owning PM so it re-coordinates the revision.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -21,6 +21,8 @@ def _orch(
|
||||
) -> tuple[AgentOrchestrator, AsyncMock]:
|
||||
"""A bare orchestrator with its dispatch helpers mocked; returns (orch, spawn)."""
|
||||
orch = object.__new__(AgentOrchestrator)
|
||||
cast("Any", orch)._pm_respawn_tracker = {}
|
||||
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
|
||||
spawn = AsyncMock()
|
||||
object.__setattr__(orch, "_fetch_tasks", AsyncMock(return_value=tasks))
|
||||
object.__setattr__(
|
||||
|
||||
@@ -687,6 +687,39 @@ def test_preview_batch_computes_waves_without_creating() -> None:
|
||||
assert isinstance(result["warnings"], list)
|
||||
|
||||
|
||||
def test_preview_batch_honours_declared_depends_on() -> None:
|
||||
"""B1b: a draft's declared depends_on becomes a real edge even when the
|
||||
collision surfaces are disjoint (the live S6 break: declared waves were
|
||||
dropped because the intends_to_touch globs didn't overlap)."""
|
||||
service = get_prompter_service()
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{"title": "A", "intends_to_touch": ["a.py"]},
|
||||
{"title": "B", "intends_to_touch": ["b.py"], "depends_on": [0]},
|
||||
]
|
||||
result = service.preview_batch(drafts)
|
||||
assert result["waves"] == [[0], [1]]
|
||||
|
||||
|
||||
def test_preview_batch_coerces_string_declared_indices() -> None:
|
||||
"""The LLM sometimes emits depends_on indices as strings ("0")."""
|
||||
service = get_prompter_service()
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{"title": "A", "intends_to_touch": ["a.py"]},
|
||||
{"title": "B", "intends_to_touch": ["b.py"], "depends_on": ["0"]},
|
||||
]
|
||||
result = service.preview_batch(drafts)
|
||||
assert result["waves"] == [[0], [1]]
|
||||
|
||||
|
||||
def test_preview_batch_rejects_out_of_range_declared_dep() -> None:
|
||||
service = get_prompter_service()
|
||||
drafts: list[dict[str, Any]] = [
|
||||
{"title": "A", "intends_to_touch": ["a.py"], "depends_on": [9]},
|
||||
]
|
||||
with pytest.raises(ValidationError):
|
||||
service.preview_batch(drafts)
|
||||
|
||||
|
||||
def test_preview_batch_rejects_empty() -> None:
|
||||
service = get_prompter_service()
|
||||
with pytest.raises(ValidationError):
|
||||
|
||||
@@ -429,3 +429,58 @@ def test_by_osmosis_skips_empty_predecessor_group() -> None:
|
||||
|
||||
def test_by_osmosis_no_edges_when_no_predecessor_groups() -> None:
|
||||
assert by_osmosis_tail_dev_tasks(True, []) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Declared dependencies (B1b — the CEO's "Depends on" lists become real edges)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live break (S6, 2026-07-01): the draft declared depends-on S1+R2+R3 but only
|
||||
# the analyzer's file-overlap edges were wired, so S6 started 90s after
|
||||
# still-running R3. Declared edges are authoritative; derived edges remain the
|
||||
# safety net — analyze() takes the union.
|
||||
|
||||
|
||||
def test_declared_dependency_creates_edge_between_disjoint_surfaces() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["a/x.py"], False, False),
|
||||
DraftSurface(1, 1, ["b/y.py"], False, False, declared_depends_on=(0,)),
|
||||
]
|
||||
plan = SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
assert (0, 1) in plan.edges
|
||||
assert _wave_of(plan.waves, 0) < _wave_of(plan.waves, 1)
|
||||
|
||||
|
||||
def test_declared_union_with_derived_dedupes() -> None:
|
||||
# Overlap already derives (0, 1) (idx 0 more important); declaring it too
|
||||
# must not duplicate the edge.
|
||||
s = [
|
||||
DraftSurface(0, 1, ["svc/threats.py"], False, False),
|
||||
DraftSurface(1, 2, ["svc/threats.py"], False, False, declared_depends_on=(0,)),
|
||||
]
|
||||
plan = SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
assert plan.edges.count((0, 1)) == 1
|
||||
|
||||
|
||||
def test_declared_out_of_range_rejected() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["a/x.py"], False, False, declared_depends_on=(7,)),
|
||||
]
|
||||
with pytest.raises(SequencingError):
|
||||
SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
|
||||
|
||||
def test_declared_self_dependency_rejected() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["a/x.py"], False, False, declared_depends_on=(0,)),
|
||||
]
|
||||
with pytest.raises(SequencingError):
|
||||
SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
|
||||
|
||||
def test_declared_cycle_rejected() -> None:
|
||||
s = [
|
||||
DraftSurface(0, 1, ["a/x.py"], False, False, declared_depends_on=(1,)),
|
||||
DraftSurface(1, 1, ["b/y.py"], False, False, declared_depends_on=(0,)),
|
||||
]
|
||||
with pytest.raises(SequencingError):
|
||||
SequencingService().analyze(s, _backend, {"backend": 2})
|
||||
|
||||
@@ -7,7 +7,7 @@ session boundary and checks the method's contract.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -700,6 +700,176 @@ async def test_admin_set_status_blocked_restore_attributes_admin_actor() -> None
|
||||
assert not any(r.agent_id == dev for r in rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_status_blocked_to_review_state_clears_claim() -> None:
|
||||
"""Forcing a BLOCKED task into a review/queue state must clear the claim.
|
||||
|
||||
Live wedge (2026-07-01 22:13Z): the CEO forced blocked ->
|
||||
awaiting_pm_review, the stale escalation claim (main-pm) survived, and the
|
||||
respawned cell PM was handed the task by give_me_work while every
|
||||
note(task_id=...) bounced not_authorized "you do not hold the claim" — so
|
||||
it re-blocked. Review-state targets are re-claimed via the claim verbs, so
|
||||
the override must leave no stale claimant behind.
|
||||
"""
|
||||
pm = uuid4()
|
||||
task = _build_task(
|
||||
status=TaskStatus.BLOCKED,
|
||||
assigned_to=pm,
|
||||
claimed_by=pm,
|
||||
claimed_at=datetime.now(UTC),
|
||||
active_claimant_id=pm,
|
||||
pre_block_state="awaiting_pm_review",
|
||||
pre_block_assignee=pm,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.AWAITING_PM_REVIEW)
|
||||
assert out is task
|
||||
assert task.status == TaskStatus.AWAITING_PM_REVIEW
|
||||
assert task.claimed_by is None
|
||||
assert task.claimed_at is None
|
||||
assert task.active_claimant_id is None
|
||||
# The consumed snapshot must not survive to confuse a later unblock.
|
||||
assert task.pre_block_state is None
|
||||
assert task.pre_block_assignee is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_status_blocked_to_needs_revision_clears_claim() -> None:
|
||||
"""blocked -> needs_revision (the other live recovery target) also clears
|
||||
the claim so the revision coordinator / re-claiming dev starts clean."""
|
||||
pm = uuid4()
|
||||
task = _build_task(
|
||||
status=TaskStatus.BLOCKED,
|
||||
assigned_to=pm,
|
||||
claimed_by=pm,
|
||||
claimed_at=datetime.now(UTC),
|
||||
active_claimant_id=pm,
|
||||
pre_block_state="awaiting_pm_review",
|
||||
pre_block_assignee=pm,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.NEEDS_REVISION)
|
||||
assert out is task
|
||||
assert task.status == TaskStatus.NEEDS_REVISION
|
||||
assert task.claimed_by is None
|
||||
assert task.claimed_at is None
|
||||
assert task.active_claimant_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_status_non_blocked_source_keeps_claim() -> None:
|
||||
"""The claim-clear fires only when leaving BLOCKED — a plain override on a
|
||||
non-blocked task (e.g. completing a reviewed task) must not strip the
|
||||
owner's claim."""
|
||||
owner = uuid4()
|
||||
claimed_at = datetime.now(UTC)
|
||||
task = _build_task(
|
||||
status=TaskStatus.AWAITING_PM_REVIEW,
|
||||
assigned_to=owner,
|
||||
claimed_by=owner,
|
||||
claimed_at=claimed_at,
|
||||
active_claimant_id=owner,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
await svc.admin_set_status(task.id, TaskStatus.COMPLETED)
|
||||
assert task.claimed_by == owner
|
||||
assert task.claimed_at == claimed_at
|
||||
assert task.active_claimant_id == owner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_routes_leaf_back_to_original_dev() -> None:
|
||||
"""PM merge-review reject: awaiting_pm_review -> needs_revision, issues
|
||||
appended for the dev, task re-owned by the original developer (the QA-fail
|
||||
routing), stale claimant cleared."""
|
||||
dev = uuid4()
|
||||
pm = uuid4()
|
||||
task = _build_task(
|
||||
status=TaskStatus.AWAITING_PM_REVIEW,
|
||||
assigned_to=pm,
|
||||
claimed_by=pm,
|
||||
active_claimant_id=pm,
|
||||
dev_notes=None,
|
||||
orchestration_markers={"original_developer": str(dev)},
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
_bind(svc, "_validate_and_set_status", MagicMock())
|
||||
out = await svc.request_changes(
|
||||
pm, task.id, "scope violation", ["frontend/CLAUDE.md modified out of scope"]
|
||||
)
|
||||
assert out is task
|
||||
assert task.assigned_to == dev
|
||||
assert task.claimed_by == dev
|
||||
assert task.active_claimant_id is None
|
||||
assert "[PM REVIEW ISSUES]" in (task.dev_notes or "")
|
||||
assert "frontend/CLAUDE.md modified out of scope" in (task.dev_notes or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_without_dev_marker_routes_to_revision_pm() -> None:
|
||||
"""An assembled task (no original-developer marker) lands on the PM who
|
||||
owns its revision — same fallback pr_fail uses."""
|
||||
cell_pm = SimpleNamespace(id=uuid4())
|
||||
actor = uuid4()
|
||||
task = _build_task(
|
||||
status=TaskStatus.AWAITING_PM_REVIEW,
|
||||
assigned_to=actor,
|
||||
claimed_by=actor,
|
||||
active_claimant_id=actor,
|
||||
dev_notes=None,
|
||||
orchestration_markers=None,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
_bind(svc, "_validate_and_set_status", MagicMock())
|
||||
_bind(svc, "_revision_pm_for_task", AsyncMock(return_value=cell_pm))
|
||||
out = await svc.request_changes(actor, task.id, "assembly issue", ["bad merge"])
|
||||
assert out is task
|
||||
assert task.assigned_to == cell_pm.id
|
||||
assert task.claimed_by == cell_pm.id
|
||||
assert task.active_claimant_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_changes_rejects_wrong_status() -> None:
|
||||
"""Only awaiting_pm_review is a valid source — anything else returns None
|
||||
(the gateway spec gate rejects earlier; this is the service backstop)."""
|
||||
task = _build_task(status=TaskStatus.IN_PROGRESS)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.request_changes(uuid4(), task.id, "notes", ["issue"])
|
||||
assert out is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_set_status_pre_block_restore_syncs_active_claimant() -> None:
|
||||
"""The pending/in_progress restore path re-owns the task to the pre-block
|
||||
dev — active_claimant_id must follow, or the restored dev's content writes
|
||||
bounce off the stale claimant exactly like the review-state wedge."""
|
||||
dev = uuid4()
|
||||
pm = uuid4()
|
||||
task = _build_task(
|
||||
status=TaskStatus.BLOCKED,
|
||||
assigned_to=pm,
|
||||
claimed_by=pm,
|
||||
active_claimant_id=pm,
|
||||
branch_name="feature/backend/abc--def",
|
||||
pre_block_state="in_progress",
|
||||
pre_block_assignee=dev,
|
||||
)
|
||||
svc = TaskService(MagicMock(flush=AsyncMock()))
|
||||
_bind(svc, "get", AsyncMock(return_value=task))
|
||||
out = await svc.admin_set_status(task.id, TaskStatus.IN_PROGRESS)
|
||||
assert out is task
|
||||
assert task.assigned_to == dev
|
||||
assert task.claimed_by == dev
|
||||
assert task.active_claimant_id == dev
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_block_restore_skips_revision_count_bump() -> None:
|
||||
"""#101 Gap B: restoring a blocked task to its snapshotted needs_revision
|
||||
|
||||
Reference in New Issue
Block a user