"""天工阁应用服务:受托任务(分解/并行/验收)与后台 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, }