Files
roboco/tests/integration/test_foundation_phase1_smoke.py
1c87a4e4e4 Leak fixes, gate green again, uv/CI hardening, e2e lifecycle smoke harness (#294)
* test: align phase1 smoke mock with the armed team-match gate

The 8e5f84c4 sweep fixed 13 test files' inconsistent-team mocks but ran
only the gateway/foundation/runtime subsets; the full gate caught this
integration mock whose parent task carried an auto-generated MagicMock
team and died on not_authorized before the incomplete_input assertion.

* fix(runtime): attribute every agent.spawned audit to its dispatcher

A rogue spawner could not be identified live (2026-07-02): agent.spawned
rows carry container/model but not which dispatch loop launched them.
spawn_agent now takes spawned_by, stamps it into the spawned/spawn_failed
audit details, every call site passes its loop name, and an AST sweep
test holds future callers to it.

* fix(api): admin-complete refuses when the task's PR is still open

PATCH status=completed on a task with an OPEN PR stranded its commits
unmerged (bit the CEO twice live 2026-07-02). The override now refuses
with the PR number/URL and the consequence before the generic hatch
text; force:true stays the deliberate, audited escape.

* fix(panel): awaiting_ceo_approval offers the working ceo-approve path

The header's only approve action was Approve & Merge (POST
/approve-and-merge, no notes) which 400s NO_PR on a branchless MegaTask
umbrella — the CEO's approve button just failed. Primary action is now
Approve & Complete via the CeoApproveDialog (POST /ceo-approve, notes
>=20 chars, proven live); Approve & Merge stays for PR-bearing tasks.

* test: stop leaking self-heal + rate-limit state into live Redis

Two test files wrote real keys into a developer's localhost Redis:
self-heal originate tests left self_heal:notified:* (2h TTL) and the
i_am_blocked rate-limited tests left a NO-TTL 'anthropic rate-limited'
tracker blob — order/state-dependent poison for anything reading the
real tracker, and the prime suspect class for the one-off
test_self_heal_engine full-run failure (not reproduced in 5x dir runs,
adversarial orders, and a green full gate). Both files now point the
computed redis_url at an unreachable port; the engines' fail-open paths
keep every assertion intact. Leaked keys scrubbed live.

* docs: changelog + map delta for the leak-fix batch; mypy-clean attribution test

The attribution test's direct method assignments tripped the full gate's
mypy (method-assign) — switched to the house monkeypatch idiom, no
suppressions.

* fix(gate): clear the ten xenon C-ranks; isolate all tests from live Redis

Master CI has been red at the phase1 smoke test, so neither CI nor a
local full gate had reached the xenon step since the team-match sweep —
whose inline 'agent_team=str(agent.team) if ...' kwarg pushed nine verb
bodies from B(10) to C(11-12) unseen. A shared actor_context_fields()
(_protocol.py) computes (actor_slug, agent_team) once per verb, restoring
all nine to B with zero behavior change; the new admin-complete override
helper extraction does the same for routes/tasks.py.

tests/conftest.py gains an autouse fixture pointing the computed
redis_url at an unreachable port for every test — the root fix for the
three families caught writing live-Redis keys (self-heal dedupe,
rate-limit tracker, notification purpose-dedupe); no test uses a real
Redis, and every production path is fail-open by design.

* refactor(runtime): delete the never-wired dispatch-time spawn cooldown

_safe_spawn / gateway_pre_spawn_check / trigger_filter had no caller in
the repo's entire history (87ef42bf only flipped the flag). Its five
rules are superseded: provider parking runs inside spawn_agent, claim
freshness is the guards+reaper, runaway respawns are the progress-aware
breaker + notification cooldown; the per-task cooldown rule would
queue-stall every normal stage handoff if wired today. gateway_triggers
table kept inert. Ratified by the CEO over wiring it.

* build: serialize uv — gate recipes never implicitly sync the venv

Every uv run re-syncs implicitly, so a background make quality plus any
foreground uv run raced two writers on one .venv and tore site-packages
apart (the recurring rich/pip/bandit ImportError corruption; bit twice
today, four times on 2026-07-02's first session). UV_NO_SYNC=1 is now
exported Makefile-wide and quality/quality-fast/gate depend on one
explicit up-front sync step.

* fix(git): PR/merge/branch REST calls honor github_api_base_url

Fifteen sites hardcoded https://api.github.com while the CI-run and
open-PR-list calls already read settings.github_api_base_url — a GHE or
test override silently applied to half the surface. One _api_base()
helper keeps them uniform; default behavior unchanged.

* ci: split the monolith — backend CI, Panel CI, E2E Smoke

ci.yml keeps its file name and the backend quality job only (self-heal /
ci-watch / release-readiness default to the ci.yml workflow); the panel
job moves to panel-ci.yml scoped to panel/**, and the new scripted-agent
lifecycle smoke gets e2e-smoke.yml + a make e2e-smoke target (env-gated
out of the default pytest run). Trade: a panel-only red now lands on
Panel CI, which the ci.yml-pinned watch engines don't see.

* feat(tests): e2e lifecycle smoke harness — scripted agents, real gates

tests/e2e_smoke stands up the real API (flow/do routers + middleware on
uvicorn) over the ephemeral test Postgres, a local bare origin standing
in for GitHub, and a fake GitHub REST layer whose merges are real git
merges. A deterministic driver reloads the real MCP flow/do modules per
agent and walks claim (real clone + worktree) -> tracing-gap -> note ->
plan gate -> commit -> PR -> the full i_am_done ladder -> QA verdicts ->
documenter -> awaiting_pm_review in ~5s. Runs via make e2e-smoke + its
own CI workflow; skipped (env-gated) in the default suite. The
freeze-lift condition's first half: scenario 1 green.

---------

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

158 lines
5.4 KiB
Python

"""Foundation Phase 1 smoke gate.
End-to-end: a delegate call that would have produced a skeleton task
pre-Phase-1 must now return Envelope.incomplete_input with a populated
field_hints map. No silent fallback; no ``["completed and reviewed by
assignee"]`` ever lands in the DB.
"""
from __future__ import annotations
import subprocess
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,
DelegateInputs,
)
# Lower bound for a useful field hint. The canonical
# ``_HINT_ACCEPTANCE_CRITERIA`` text is ~200 chars; anything under this
# would mean the hint string was truncated or replaced with a stub.
_MIN_FIELD_HINT_LEN = 30
def _make_deps(**overrides: Any) -> ChoreographerDeps:
"""Build a ChoreographerDeps with AsyncMock services + empty evidence repo.
Mirrors ``tests/unit/gateway/test_delegate_incomplete_input.py``: every
evidence_repo lookup the briefing assembler reaches for must return
``[]`` so the briefing build does not raise on a coroutine result.
"""
base: dict[str, Any] = {
"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 = []
# C8: default-fresh journal:decision so PM-decision gate passes for
# callers that don't override the journal mock.
_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)
@pytest.mark.asyncio
async def test_skeleton_task_path_returns_incomplete_input() -> None:
"""The 2026-05-10 smoke run produced subtasks with empty
acceptance_criteria via the gateway. After Phase 1, that exact
sequence must produce incomplete_input rejections instead.
"""
pm_id = uuid4()
parent = MagicMock(
id=uuid4(),
project_id=uuid4(),
status="in_progress",
assigned_to=pm_id,
priority=2,
team="backend",
)
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(
id=pm_id, role="cell_pm", team="backend", slug="be-pm"
)
task_svc.get_subtasks.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
# The exact shape from the failing smoke run: acceptance_criteria
# missing, optional fields not provided.
env = await c.delegate(
pm_id,
parent.id,
DelegateInputs(
title="Branch naming smoke test",
description=(
"Verify branch creation follows the feature/team/task convention."
),
assigned_to="be-dev-1",
team="backend",
task_type="code",
nature="technical",
estimated_complexity="medium",
acceptance_criteria=None,
),
)
body = env.as_dict()
assert body["error"] == "incomplete_input", (
f"expected incomplete_input, got: {body}"
)
assert "acceptance_criteria" in body["missing"]
# The agent learns from field_hints what to fill:
assert "acceptance_criteria" in body["field_hints"]
assert len(body["field_hints"]["acceptance_criteria"]) > _MIN_FIELD_HINT_LEN
# Skeleton task never reached the DB.
task_svc.create_subtask.assert_not_awaited()
def test_no_silent_fallback_phrase_in_repo() -> None:
"""Production code must not contain the placeholder string outside of
documented locations.
Allowed appearances after Tasks 12 + 18 + 20:
- ``roboco/foundation/policy/task_completeness.py`` — the DENYLIST
entry plus the ``_HINT_ACCEPTANCE_CRITERIA`` text that names the
phrase as a known evasion.
- ``roboco/api/routes/tasks.py`` — comment in the POST /tasks handler
explaining why the route runs ``task_completeness.check`` (Task 20).
- ``roboco/services/task.py`` — docstring on ``create_subtask``
documenting the Task 18 deletion of the silent fallback.
Any other appearance is a real Phase 1 gap.
"""
proc = subprocess.run(
# ``-I`` skips binary files so stray ``__pycache__/*.pyc`` matches
# (left over from a previous run) don't contaminate the grep.
["grep", "-rnI", "completed and reviewed by assignee", "roboco/"],
capture_output=True,
text=True,
check=False,
)
allowed_files = (
"roboco/foundation/policy/task_completeness.py",
"roboco/api/routes/tasks.py",
"roboco/services/task.py",
)
suspicious: list[str] = []
for line in proc.stdout.splitlines():
if not line:
continue
if any(line.startswith(f"{path}:") for path in allowed_files):
continue
suspicious.append(line)
assert suspicious == [], (
f"silent-fallback phrase still present in production code: {suspicious}"
)