M8:天工阁、叩心与辰正——受托任务后台并行、叩心四约束、时态感知投影

- 天工阁域 domains/tiangong:backstage_tasks(委托包+验收标准+结果证据);有限分解(parent 链)与受控并行(各自后台 Run);子任务全终态级联父任务进入待验收;按证据 accept/reject
- kernel.start_backstage_run(kind=background_task):loop 后台分支不占前台席位、与前台对话并行;断言放宽容纳无人物功能任务;结果只经回调回呈任务——无身份写入路径
- 叩心域 domains/kouxin:knocking_candidates(event/time_anchor/manual 触发 + 草稿 + 裁决快照);四约束裁决(总开关→静默窗口【本地时区判定】→24h 频率→预算),dispatch 未过 429 且快照留痕;候选默认不自动发送;expire_stale 过期推进不触碰缘契
- 辰正 domains/zhengchen:temporal_context() 复用 time_sense 计算核(快照/农历/季节),产出【时态感知】投影段接入镜树生成 prompt(客观时间语境,不附情绪臆测——守一纪律)
- 退役:modules/goal_orchestrator|planner|perception|big_cycle、application/planning、services/governance|goal_persist、candidates 表链(imports 候选抽取 API 面)、goals/plans/agent_roster/perception_channels/big_cycles/旧候选表(迁移 009);前端 admin goals/plans/agents/perception/world 页与六组件;governance 只留 GenerationPreferences;admin 治理面收敛为司命+百工两枢;pivots 健康以域表计数重建
- 验收(test_m8_tiangong 五用例):后台任务无身份写入(记忆/魂籍/缘契零新增);后台与前台并行;分解级联;叩心四约束逐项;不回应不降低关系(缘契维度/状态/版本零变化)
- 四套件:后端 345、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 009 净库干净
This commit is contained in:
chenxin committed 2026-08-28 09:13:39 +08:00
1 parent b419e0afde
commit c63b75152a
68 files changed
+1205 -4750

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
"""M8 验收测试(rewrite-plan M8 验收门)。
① 后台任务不构成第二意识(无身份写入路径:完成后记忆/魂籍/缘契零新增);
② 后台 Run 不占前台席位(并行于前台对话);
③ 叩心受总开关与静默约束;不回应不降低关系(无关系副作用断言)。
"""
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, time, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.domains import kouxin
from app.main import app
from app.models.jingsheng import IdentityRecord
from app.models.xirang import Memory
from app.domains.yuanqi.models import Covenant
from app.kernel import Kernel, KernelEventLog
from app.services.event_store import DbEventStore
from app.shenshi_client.client import EchoShenshi
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
app.state.kernel = Kernel(
shenshi=EchoShenshi(),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(DbEventStore(AsyncSessionLocal)),
timeout_seconds=10.0,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
app.state.kernel = None
async def _character(client: AsyncClient) -> dict:
resp = await client.get("/api/v1/characters/active")
assert resp.status_code == 200, resp.text
return resp.json()
async def _wait_task(client: AsyncClient, task_id: str, terminal: set[str], timeout_s: float = 15.0) -> dict:
import asyncio
async def poll():
while True:
resp = await client.get("/api/v1/tasks", params={"parent": "all"})
task = next((t for t in resp.json() if t["id"] == task_id), None)
assert task is not None
if task["status"] in terminal:
return task
await asyncio.sleep(0.2)
return await asyncio.wait_for(poll(), timeout=timeout_s)
# ── ①/② 天工阁:后台 Run 无身份写入、不占前台席位 ──
@pytest.mark.asyncio
async def test_backstage_task_no_identity_writes(client: AsyncClient) -> None:
character = await _character(client)
async def _counts() -> tuple[int, int, int]:
async with AsyncSessionLocal() as session:
mem = len(list((await session.execute(select(Memory).where(Memory.character_id == character["id"]))).scalars()))
ids = len(list((await session.execute(select(IdentityRecord).where(IdentityRecord.character_id == character["id"]))).scalars()))
cov = len(list((await session.execute(select(Covenant).where(Covenant.character_id == character["id"]))).scalars()))
return mem, ids, cov
before = await _counts()
task = await client.post(
"/api/v1/tasks",
json={
"character_id": character["id"],
"title": "整理周报要点",
"brief": "把最近一周的镜树归真内容整理为十条要点",
"acceptance_criteria": "输出不超过十条要点列表",
},
)
assert task.status_code == 201, task.text
task_id = task.json()["id"]
dispatched = await client.post(f"/api/v1/tasks/{task_id}/dispatch", json={})
assert dispatched.status_code == 200, dispatched.text
assert dispatched.json()["status"] == "running"
done = await _wait_task(client, task_id, {"awaiting_acceptance", "failed"})
assert done["status"] == "awaiting_acceptance", done
assert done["run_id"], "后台 Run 已关联"
assert done["result_digest"], "结果证据已回填"
# 无身份写入路径:记忆/魂籍/缘契零新增(第二意识断言)
after = await _counts()
assert after == before, f"后台任务不得写身份路径:{before} → {after}"
# 按证据验收
accepted = await client.post(f"/api/v1/tasks/{task_id}/accept", json={"accepted": True})
assert accepted.status_code == 200
assert accepted.json()["status"] == "accepted"
@pytest.mark.asyncio
async def test_backstage_run_parallel_with_foreground(client: AsyncClient) -> None:
"""后台 Run 运行期间,前台对话不受席位阻塞(不占前台槽位)。"""
character = await _character(client)
task = await client.post(
"/api/v1/tasks",
json={"title": "长任务", "brief": "慢速整理", "acceptance_criteria": "完成即止"},
)
task_id = task.json()["id"]
await client.post(f"/api/v1/tasks/{task_id}/dispatch", json={})
# 后台任务运行中,前台 Run 立即可启动(回声桩即刻完成——若席位被占会超时)
run = await client.post("/api/v1/runs", json={"prompt": "前台照常", "character_id": character["id"]})
assert run.status_code in (200, 201), run.text
await _wait_task(client, task_id, {"awaiting_acceptance", "failed"})
@pytest.mark.asyncio
async def test_task_decompose_and_cascade(client: AsyncClient) -> None:
parent = await client.post(
"/api/v1/tasks",
json={"title": "资料三线整理", "brief": "并行整理", "acceptance_criteria": "子任务全部完成"},
)
parent_id = parent.json()["id"]
subs = await client.post(
f"/api/v1/tasks/{parent_id}/decompose",
json={"subtasks": [
{"title": "线一", "brief": "A", "acceptance_criteria": "完成"},
{"title": "线二", "brief": "B", "acceptance_criteria": "完成"},
]},
)
assert subs.status_code == 201
sub_ids = [s["id"] for s in subs.json()]
for sid in sub_ids:
await client.post(f"/api/v1/tasks/{sid}/dispatch", json={})
for sid in sub_ids:
await _wait_task(client, sid, {"awaiting_acceptance", "failed"})
# 父任务级联进入待验收
parent_done = await _wait_task(client, parent_id, {"awaiting_acceptance", "failed"})
assert parent_done["status"] == "awaiting_acceptance"
# ── ③ 叩心约束与无关系副作用 ──
@pytest.mark.asyncio
async def test_knocking_constraints(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
character = await _character(client)
# 总开关关闭 → 派发被拒
monkeypatch.setattr(kouxin.service.settings, "knocking_enabled", False)
cand = await client.post(
"/api/v1/knocking/candidates",
json={"character_id": character["id"], "draft": "最近还好吗?"},
)
assert cand.status_code == 201
blocked = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={})
assert blocked.status_code == 429
assert "总开关" in blocked.text
monkeypatch.setattr(kouxin.service.settings, "knocking_enabled", True)
# 静默窗口(强制设为全天)→ 拒
monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_start", "00:00")
monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_end", "23:59")
blocked2 = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={})
assert blocked2.status_code == 429
assert "静默" in blocked2.text
# 频率上限=0 → 拒
monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_start", "03:00")
monkeypatch.setattr(kouxin.service.settings, "knocking_quiet_end", "04:00")
monkeypatch.setattr(kouxin.service.settings, "knocking_daily_limit", 0)
blocked3 = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={})
assert blocked3.status_code == 429
# 恢复常态(当前在 03:00–04:00 之外、上限 3)→ 允许派发
monkeypatch.setattr(kouxin.service.settings, "knocking_daily_limit", 3)
ok = await client.post(f"/api/v1/knocking/candidates/{cand.json()['id']}/dispatch", json={})
assert ok.status_code == 200, ok.text
assert ok.json()["status"] == "dispatched"
@pytest.mark.asyncio
async def test_no_response_no_relationship_side_effect(client: AsyncClient) -> None:
"""不回应不降低关系:候选过期后缘契维度零变化。"""
character = await _character(client)
# 建立缘契基线
cov = await client.post(
"/api/v1/covenants",
json={"character_id": character["id"], "dimensions": {"trust": "high", "companionship": "warm"}},
)
assert cov.status_code == 201
await client.post(f"/api/v1/covenants/{cov.json()['id']}/seal", json={"dimensions": {"trust": "high", "companionship": "warm"}})
before = (await client.get(f"/api/v1/covenants/by-character/{character['id']}")).json()
# 叩心候选发出后无人回应,直至过期
cand = await client.post(
"/api/v1/knocking/candidates",
json={"character_id": character["id"], "trigger": "time_anchor", "draft": "想到你了"},
)
cid = cand.json()["id"]
await client.post(f"/api/v1/knocking/candidates/{cid}/dispatch", json={})
expired = await kouxin.service.expire_stale(max_age_hours=0) # 立即过期
assert expired >= 1
# 无关系副作用:缘契维度与状态零变化
after = (await client.get(f"/api/v1/covenants/by-character/{character['id']}")).json()
assert after["dimensions"] == before["dimensions"]
assert after["status"] == before["status"]
assert after["version"] == before["version"], "叩心不得触发缘契版本变更"