Files
chenxin c63b75152a 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 净库干净
2026-08-28 09:13:39 +08:00

212 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""天工阁应用服务:受托任务(分解/并行/验收)与后台 Run 编排。
铁律(identity-continuity.md §4 / mandate-and-capability-model.md §7):
- 后台受托 Run 不占前台席位(并行于前台对话);
- 任务 Run 不是第二意识:**无身份写入路径**——不触发记忆候选、不写魂籍/铸魂、
不建缘契(专项断言钉死);产出只作为任务结果证据回呈;
- 达到预算/期限或连续失败后保留证据、停止并请命(P1 以任务失败态承载)。
"""
from __future__ import annotations
import json
from sqlalchemy import select
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.domains.tiangong.models import BackstageTask
from app.models.character import Character
async def create_task(
*,
owner_scope: str,
character_id: str | None,
title: str,
brief: str,
acceptance_criteria: str,
parent_task_id: str | None,
) -> dict:
"""登记受托任务(pending;分解任务挂 parent 链)。"""
if character_id:
await _owned(character_id, owner_scope)
async with AsyncSessionLocal() as session:
task = BackstageTask(
owner_scope=owner_scope,
character_id=character_id,
parent_task_id=parent_task_id,
title=title,
brief=brief,
acceptance_criteria=acceptance_criteria,
)
session.add(task)
await session.commit()
session.expunge(task)
return _task_dict(task)
async def decompose(task_id: str, owner_scope: str, subtasks: list[dict]) -> list[dict]:
"""有限分解:登记子任务(受控并行由各自后台 Run 承载)。"""
parent = await _owned_task(task_id, owner_scope)
created = []
for sub in subtasks:
created.append(
await create_task(
owner_scope=owner_scope,
character_id=parent["character_id"],
title=str(sub.get("title", ""))[:200],
brief=str(sub.get("brief", "")),
acceptance_criteria=str(sub.get("acceptance_criteria", "")),
parent_task_id=task_id,
)
)
return created
async def dispatch(task_id: str, owner_scope: str, app_state) -> dict:
"""派发后台 Run(kind=background_task,不占前台席位)。
任务 Prompt = 最小委托包(目标+验收标准+停止条件);结果回填 result_digest,
任务进入 awaiting_acceptance。人物可为空——纯功能任务无人物语境。
"""
task = await _owned_task(task_id, owner_scope)
if task["status"] not in ("pending",):
raise AppError("invalid_request", "任务不在可派发状态。", status_code=409)
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
row.status = "running"
await session.commit()
from app.kernel import Kernel
kernel: Kernel | None = getattr(app_state, "kernel", None)
if kernel is None:
raise AppError("service_unavailable", "Kernel 未就绪。", status_code=503)
prompt = (
f"【受托任务】{task['title']}\n"
f"委托包:{task['brief']}\n"
f"验收标准:{task['acceptance_criteria'] or '按委托包目标验收'}\n"
f"停止条件:完成验收标准即止;不得扩展使命。"
)
run = await kernel.start_backstage_run(
character_id=task["character_id"],
prompt=prompt,
owner_id=owner_scope,
on_task_final=_make_finalizer(task_id, owner_scope),
)
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
row.run_id = run.id
await session.commit()
await one_qi.emit(
"task.dispatched", character_id=task["character_id"],
payload={"task_id": task_id, "run_id": run.id}, source="tiangong",
)
return {"task_id": task_id, "run_id": run.id, "status": "running"}
def _make_finalizer(task_id: str, owner_scope: str):
"""Run 完成回调:回填结果、进入待验收。"""
async def _finalize(content: str, status: str) -> None:
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
if row is None or row.owner_scope != owner_scope:
return
row.result_digest = (content or "")[:4000]
row.status = "awaiting_acceptance" if status == "completed" else "failed"
row.completed_at = utc_now()
await session.commit()
await _cascade_parent(task_id)
return _finalize
async def _cascade_parent(task_id: str) -> None:
async with AsyncSessionLocal() as session:
task = await session.get(BackstageTask, task_id)
if task is None or task.parent_task_id is None:
return
parent_id = task.parent_task_id
children = (
await session.execute(
select(BackstageTask).where(BackstageTask.parent_task_id == parent_id)
)
).scalars().all()
terminal = {"awaiting_acceptance", "accepted", "rejected", "failed"}
if children and all(c.status in terminal for c in children):
parent = await session.get(BackstageTask, parent_id)
if parent is not None and parent.status in ("pending", "running"):
parent.status = "awaiting_acceptance"
parent.result_digest = "\n".join(
f"〔{c.title}〕{c.result_digest[:400]}" for c in children
)
parent.completed_at = utc_now()
await session.commit()
async def accept(task_id: str, owner_scope: str, *, accepted: bool) -> dict:
"""按证据验收:accepted / rejected。"""
await _owned_task(task_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
if row.status not in ("awaiting_acceptance", "failed"):
raise AppError("invalid_request", "任务不在待验收状态。", status_code=409)
row.status = "accepted" if accepted else "rejected"
await session.commit()
await one_qi.emit(
"task.accepted" if accepted else "task.rejected",
character_id=None, payload={"task_id": task_id}, source="tiangong",
)
return {"task_id": task_id, "status": row.status}
async def list_tasks(owner_scope: str, *, parent: str | None = "root") -> list[dict]:
"""任务清单(默认根任务;parent=None 全量)。"""
async with AsyncSessionLocal() as session:
stmt = select(BackstageTask).where(BackstageTask.owner_scope == owner_scope)
if parent == "root":
stmt = stmt.where(BackstageTask.parent_task_id.is_(None))
elif parent is not None:
stmt = stmt.where(BackstageTask.parent_task_id == parent)
rows = (await session.execute(stmt.order_by(BackstageTask.created_at.desc()))).scalars().all()
return [_task_dict(r) for r in rows]
async def _owned(character_id: str, owner_scope: str) -> None:
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。", status_code=404)
async def _owned_task(task_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
row = await session.get(BackstageTask, task_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "任务不存在。", status_code=404)
session.expunge(row)
return _task_dict(row)
def _task_dict(t: BackstageTask) -> dict:
return {
"id": t.id,
"character_id": t.character_id,
"parent_task_id": t.parent_task_id,
"title": t.title,
"brief": t.brief,
"acceptance_criteria": t.acceptance_criteria,
"status": t.status,
"result_digest": t.result_digest,
"evidence": json.loads(t.evidence_json or "{}"),
"run_id": t.run_id,
"created_at": t.created_at.isoformat() if t.created_at else None,
"completed_at": t.completed_at.isoformat() if t.completed_at else None,
}