Files
董宏凯 1aeee86984 fix(maf): harden stage-4 quality gate (P0/P1)
P0-1 migration: detect the same-named stage-3 execution_stage CHECK by sqltext (must contain rework + manager_review), SQLite rebuild / PG-MySQL drop+add, PG boolean default TRUE, validate_schema checks constraint content + seat_tasks status CHECK/unique/FKs; tests start from a real stage-3 schema.

P0-2: a missing/invalid manager review (timeout/exception/empty/invalid JSON/missing per-seat decision) is never an acceptance - system rework under the cap, contract fallback (reassign/controlled_gap/ask_user) afterwards, source=system events.

P0-3: unaccepted deliveries are isolated from the fact ledger, cross-review and final report (deferred merge, delivery_json envelope, accepted_task_results in checkpoint, accepted-only synthesis, no turn_results fallback).

P0-4: seat-task init is atomic get-or-create over the full expected set; recovery fills missing tasks without overwriting frozen contracts; quality gate blocks missing required tasks.

P1: zero config values honoured; duplicate detection persists SHA-256 conclusion digests in history (survives restarts); findings never satisfy evidence, evidence_refs must resolve to trusted ledger refs (unverified_evidence_ref); conversation SSE projection tests added and manager/finalizer messages are no longer mislabelled as seat.delivery.submitted.
2026-08-07 00:04:55 +08:00

257 lines
9.9 KiB
Python

"""Stage 4 (R3-Q) P0-4: atomic seat-task initialization and safe recovery.
The old initializer created tasks one by one and short-circuited with
``if existing: return existing`` — a crash after the first create permanently
dropped the remaining required seats on recovery, and the quality gate could
then pass on an incomplete task set.
These tests prove:
- ``get_or_create_tasks`` creates the FULL expected set in one transaction;
- after a simulated partial initialization (only the first task exists),
recovery fills the missing tasks and never touches existing rows;
- a frozen contract / lifecycle progress is NEVER overwritten;
- two concurrent recovery calls converge on one complete set without raising
unique-key violations;
- ``QualitySeatTasksExecutor._load_or_create_tasks`` derives the expected set
from the dispatch assignments and fills gaps;
- the quality gate blocks when an expected required task is missing.
"""
from __future__ import annotations
import asyncio
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.maf_roundtable.workflow_executors import (
QualityGateExecutor,
QualitySeatTasksExecutor,
RoundtableState,
)
from deerflow.persistence.maf_roundtable import make_maf_roundtable_store
from deerflow.persistence.maf_roundtable.model import (
Base,
MafRoundtableRunRow,
MafRoundtableSessionRow,
)
CONTRACT_A = {
"objective": "原始合同目标 A",
"expected_deliverable": "结论+证据",
"acceptance_criteria": ["证据"],
"required": True,
"max_rework_attempts": 2,
"fallback_policy": "reassign_then_gap",
}
CONTRACT_B = {
"objective": "试图覆盖的合同 B",
"expected_deliverable": "别的",
"acceptance_criteria": [],
"required": True,
"max_rework_attempts": 0,
"fallback_policy": "controlled_gap",
}
@pytest_asyncio.fixture
async def task_store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'tasks.db'}", echo=False)
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = make_maf_roundtable_store(sf)
now = datetime(2026, 1, 1)
async with sf() as session:
session.add(MafRoundtableSessionRow(
id="t-sess", user_id="u1", title="t", task_text="t",
mode="structured", status="pending", write_policy="auto",
manager_agent_id="manager-1", max_rounds=8,
created_at=now, updated_at=now,
))
session.add(MafRoundtableRunRow(
id="t-run", session_id="t-sess", status="pending",
created_at=now, updated_at=now,
))
await session.commit()
yield store
finally:
await engine.dispose()
def _expected_specs() -> list[dict]:
return [
{"task_key": "01:seat-a", "agent_id": "seat-a", "contract": dict(CONTRACT_A), "required": True},
{"task_key": "02:seat-b", "agent_id": "seat-b", "contract": dict(CONTRACT_A), "required": True},
{"task_key": "03:seat-c", "agent_id": "seat-c", "contract": dict(CONTRACT_A), "required": True},
]
# ── repository: atomic full-set creation ────────────────────────────────────
@pytest.mark.asyncio
async def test_get_or_create_tasks_creates_full_set(task_store):
tasks = await task_store.seat_tasks.get_or_create_tasks(
session_id="t-sess", run_id="t-run", expected=_expected_specs(),
)
assert [t["task_key"] for t in tasks] == ["01:seat-a", "02:seat-b", "03:seat-c"]
assert all(t["status"] == "assigned" for t in tasks)
assert all(t["contract"]["objective"] == "原始合同目标 A" for t in tasks)
@pytest.mark.asyncio
async def test_get_or_create_tasks_recovers_partial_initialization(task_store):
# Simulate the old crash mode: only the FIRST task survived initialization.
await task_store.seat_tasks.create_task(
session_id="t-sess", run_id="t-run", task_key="01:seat-a",
agent_id="seat-a", contract=dict(CONTRACT_A), required=True,
)
tasks = await task_store.seat_tasks.get_or_create_tasks(
session_id="t-sess", run_id="t-run", expected=_expected_specs(),
)
# The complete set comes back — the missing seats are filled in.
assert [t["task_key"] for t in tasks] == ["01:seat-a", "02:seat-b", "03:seat-c"]
by_key = {t["task_key"]: t for t in tasks}
# The pre-existing row kept its identity and frozen contract.
assert by_key["01:seat-a"]["contract"]["objective"] == "原始合同目标 A"
assert by_key["01:seat-a"]["status"] == "assigned"
@pytest.mark.asyncio
async def test_get_or_create_tasks_never_overwrites_frozen_contract(task_store):
existing = await task_store.seat_tasks.create_task(
session_id="t-sess", run_id="t-run", task_key="01:seat-a",
agent_id="seat-c", contract=dict(CONTRACT_A), required=True,
)
# Lifecycle progressed (e.g. before a restart): reassignment + attempts.
await task_store.seat_tasks.update_task(existing["id"], {
"status": "accepted",
"attempt_count": 3,
"rework_count": 2,
"reassign_count": 1,
})
specs = _expected_specs()
specs[0]["contract"] = dict(CONTRACT_B) # recovery must NOT apply this
tasks = await task_store.seat_tasks.get_or_create_tasks(
session_id="t-sess", run_id="t-run", expected=specs,
)
by_key = {t["task_key"]: t for t in tasks}
kept = by_key["01:seat-a"]
assert kept["id"] == existing["id"]
assert kept["contract"]["objective"] == "原始合同目标 A"
assert kept["agent_id"] == "seat-c"
assert kept["status"] == "accepted"
assert kept["attempt_count"] == 3
assert kept["rework_count"] == 2
assert kept["reassign_count"] == 1
@pytest.mark.asyncio
async def test_get_or_create_tasks_concurrent_recovery(task_store):
# Two recoveries race on the same empty run: both must return the complete
# set, neither may raise a unique-key violation, and exactly one row per
# task_key may exist afterwards.
results = await asyncio.gather(
task_store.seat_tasks.get_or_create_tasks(
session_id="t-sess", run_id="t-run", expected=_expected_specs(),
),
task_store.seat_tasks.get_or_create_tasks(
session_id="t-sess", run_id="t-run", expected=_expected_specs(),
),
)
for tasks in results:
assert [t["task_key"] for t in tasks] == ["01:seat-a", "02:seat-b", "03:seat-c"]
final = await task_store.seat_tasks.list_by_run("t-run")
keys = [t["task_key"] for t in final]
assert sorted(keys) == ["01:seat-a", "02:seat-b", "03:seat-c"]
assert len(keys) == len(set(keys)), "no duplicate tasks"
# ── executor: expected set from dispatch assignments ────────────────────────
def _make_state(**overrides) -> RoundtableState:
kwargs = {
"session_id": "t-sess",
"run_id": "t-run",
"task_text": "分析市场",
"mode": "structured",
"model_name": None,
"reasoning_effort": None,
"max_rounds": 8,
"write_policy": "auto",
"manager_agent_id": "manager-1",
"seat_agent_ids": ["seat-a", "seat-b"],
"thread_map": {},
"capability_snapshot": None,
"seat_timeout_seconds": 5,
"schema_repair_enabled": True,
"intent_type": "parallel_consultation",
"dispatch_plan": {
"execution_mode": "parallel",
"assignments": [
{"agent_id": "seat-a", "task_goal": "目标一", "delivery_requirements": ["证据"]},
{"agent_id": "seat-b", "task_goal": "目标二", "delivery_requirements": ["建议"]},
],
},
"dispatch_contract_enabled": True,
"quality_gate_enabled": True,
}
kwargs.update(overrides)
return RoundtableState(**kwargs)
@pytest.mark.asyncio
async def test_executor_load_or_create_fills_missing_tasks(task_store):
# Partial init: only the first assignment's task exists.
await task_store.seat_tasks.create_task(
session_id="t-sess", run_id="t-run", task_key="01:seat-a",
agent_id="seat-a", contract=dict(CONTRACT_A), required=True,
)
state = _make_state()
adapter = SimpleNamespace(_service=SimpleNamespace(store=task_store))
executor = QualitySeatTasksExecutor(adapter, state)
tasks = await executor._load_or_create_tasks()
assert [t["task_key"] for t in tasks] == ["01:seat-a", "02:seat-b"]
by_key = {t["task_key"]: t for t in tasks}
# Existing contract untouched; missing task created from the assignment.
assert by_key["01:seat-a"]["contract"]["objective"] == "原始合同目标 A"
assert by_key["02:seat-b"]["contract"]["objective"] == "目标二"
assert by_key["02:seat-b"]["status"] == "assigned"
# Idempotent second recovery.
again = await executor._load_or_create_tasks()
assert [t["id"] for t in again] == [t["id"] for t in tasks]
@pytest.mark.asyncio
async def test_quality_gate_blocks_missing_required_task(task_store):
state = _make_state()
# Only ONE of the two expected required tasks is represented.
state.seat_task_statuses = {
"01:seat-a": {
"task_key": "01:seat-a", "status": "accepted",
"agent_id": "seat-a", "required": True,
},
}
adapter = SimpleNamespace(
_service=SimpleNamespace(store=SimpleNamespace(seat_tasks=SimpleNamespace())),
_emit_critical=AsyncMock(),
)
gate = QualityGateExecutor(adapter, state)
ctx = SimpleNamespace(send_message=AsyncMock(), yield_output=AsyncMock())
with pytest.raises(RuntimeError, match="quality gate blocked"):
await gate.handle({}, ctx)
assert state.status != "completed"