agent-Specialization/server/runtime/service.py
JOJO d9bd599c1c feat(runtime): Gateway 独立启动与公共协议落地(改造第 0-3 步)
- 闭环修复:审批 task_id 显式化(N2)、通知回退路径门闸 finally 释放(N1)、
  personalization 原子写(S10,公共 atomic_write_json)
- server.tasks 拆解:__init__ 轻量化、Blueprint/路由移至 blueprint.py/web.py,
  app_legacy 装配点惰性挂载;models.py 循环依赖死导入移除
- 执行链 Web 解耦:emit_event/run_background 安全包装(socketio 未绑定静默),
  清除 4 处裸 socketio.emit(含 task_stopped 先写事件流再推送的顺序修复)
- RuntimeService 补建对话下沉:chat 任务无 conversation_id 时服务层装配,
  客户端无需复制 Web 两步流程
- 事件协议:get_task_events 返回 meta.window_start 缺口检测水位
- 验收:test_runtime_standalone_lifecycle 子进程隔离(config import 固化问题),
  独立启动 + 协议全链路 + 审批语义全绿;全量 74 测试失败恰为 4 项存量
2026-09-07 23:00:15 +08:00

181 lines
8.2 KiB
Python
Raw 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.

"""RuntimeService公共任务受理与控制入口契约 docs/runtime_contract.md §4.2)。
定位WebHTTP 适配层、CLI、未来的定时触发器等调用方共用的任务入口。
本服务只做「受理裁决 + 显式上下文转发 + 控制委托」,不持有任务状态——
任务记录、事件流、门闸、保存保护仍由既有 TaskManager / main_task_gate /
conversation_manager 承载(契约 §2 状态责任表不变)。
兼容期说明create_task 内部把 RuntimeContext 转换为既有
session_data 快照传入 create_chat_task任务线程已改为显式 RuntimeIdentity
驱动资源装配test_request_context 桥已拆除session_data 快照仍承载
门闸 token、事件回放等内部指令的跨线程传递。
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from modules.i18n import tr
from server.runtime.context import RuntimeContext
class RuntimeService:
"""公共任务入口。无状态:全部状态委托给 task_manager 单例。"""
# ---- 受理 ----
def create_task(self, ctx: RuntimeContext):
"""受理一轮 Run校验显式上下文 → 互斥裁决create_chat_task 内)→ 登记 → 起执行。
抛错契约(适配层负责映射 HTTP 状态码):
- ValueError上下文/参数非法400
- RuntimeError同对话已有运行中 chat 任务等业务冲突409
"""
ctx.validate()
# 延迟导入避免循环server.tasks 的 blueprint 链不依赖本包
from server.tasks import task_manager
params = ctx.params
conversation_id = params.conversation_id
if not conversation_id and str(params.task_type or "chat").strip().lower() == "chat":
# 对话级隔离兜底(自 server/tasks/api.py 下沉chat 任务必须落在
# 对话级 terminal 上运行。补建对话文件是装配职责,收在服务层单点,
# Web/CLI/定时触发器等调用方无需各自实现「先建会话再发任务」。
conversation_id = self._ensure_conversation_for_chat(ctx)
return task_manager.create_chat_task(
ctx.principal.username,
ctx.principal.workspace_id,
params.message,
list(params.images or []),
conversation_id,
videos=list(params.videos or []),
model_key=params.model_key,
thinking_mode=params.thinking_mode,
run_mode=params.run_mode,
max_iterations=params.max_iterations,
session_data=ctx.to_session_data(),
message_source=params.message_source,
goal_mode=params.goal_mode,
skill_context_messages=list(params.skill_context_messages or []),
files=list(params.files or []),
task_type=params.task_type,
)
@staticmethod
def _ensure_conversation_for_chat(ctx: RuntimeContext) -> Optional[str]:
"""chat 任务未携带 conversation_id 时补建对话文件。
失败时返回 None容错语义与原适配层兜底一致任务线程内
ensure_conversation_loaded 仍有最终兜底,但会失去对话级隔离,
仅作为极端降级路径存在)。
"""
try:
from server.context import RuntimeIdentity, get_user_resources
from server.utils_common import debug_log
p = ctx.principal
params = ctx.params
identity = RuntimeIdentity(
host_mode=p.host_mode,
host_workspace_id=p.host_workspace_id,
is_api_user=p.is_api_user,
role=p.role,
preferred_model_key=params.model_key or p.preferred_model_key,
preferred_run_mode=params.run_mode or p.preferred_run_mode,
preferred_thinking_mode=(
params.thinking_mode if params.thinking_mode is not None else p.preferred_thinking_mode
),
)
terminal, workspace = get_user_resources(
p.username, workspace_id=p.workspace_id, update_session=False, identity=identity
)
cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None)
if cm is None or workspace is None:
return None
run_mode = params.run_mode or p.preferred_run_mode or "fast"
if run_mode not in {"fast", "thinking", "deep"}:
run_mode = "fast"
thinking_mode = params.thinking_mode
if thinking_mode is None:
thinking_mode = p.preferred_thinking_mode
thinking_mode = bool(thinking_mode) if thinking_mode is not None else (run_mode != "fast")
conversation_id = cm.create_conversation(
project_path=str(getattr(workspace, "project_path", "") or "."),
run_mode=run_mode,
thinking_mode=thinking_mode,
model_key=params.model_key or p.preferred_model_key,
)
debug_log(f"[RuntimeService] 未携带 conversation_id已补建对话: {conversation_id}")
return conversation_id
except Exception as exc:
try:
from server.utils_common import debug_log
debug_log(f"[RuntimeService] 补建对话失败(继续按无 cid 处理): {exc}")
except Exception:
pass
return None
# ---- 控制 ----
def cancel_task(self, username: str, task_id: str) -> bool:
"""停止主 Run不触碰后台工作者后台任务有独立控制入口"""
from server.tasks import task_manager
return task_manager.cancel_task(username, task_id)
def enqueue_runtime_guidance(
self, username: str, task_id: str, message: str, source: Optional[str] = None
) -> Dict[str, Any]:
from server.tasks import task_manager
return task_manager.enqueue_runtime_guidance(username, task_id, message, source=source)
def enqueue_runtime_pending_message(
self, username: str, task_id: str, message: str, files: Optional[List[str]] = None
) -> Dict[str, Any]:
from server.tasks import task_manager
return task_manager.enqueue_runtime_pending_message(username, task_id, message, files=files)
def remove_runtime_pending_message(self, username: str, task_id: str, message_id: str) -> Dict[str, Any]:
from server.tasks import task_manager
return task_manager.remove_runtime_pending_message(username, task_id, message_id)
def promote_runtime_pending_to_guidance(self, username: str, task_id: str, message_id: str) -> Dict[str, Any]:
from server.tasks import task_manager
return task_manager.promote_runtime_pending_to_guidance(username, task_id, message_id)
# ---- 观察(内部接口;后台调用方不必为观察任务发 HTTP 请求)----
def get_task(self, username: str, task_id: str):
from server.tasks import task_manager
return task_manager.get_task(username, task_id)
def get_task_events(
self, username: str, task_id: str, offset: int
) -> Tuple[Optional[List[Dict[str, Any]]], Optional[int], Optional[str], Optional[Dict[str, Any]]]:
"""按 offset 增量读取任务事件流idx/offset 协议,与 REST 轮询同一语义)。
返回 (events, next_offset, error, meta)。error 非空表示任务不存在或无权访问。
meta 携带缺口检测水位:``window_start`` = 事件窗口当前最小 idx
(协议 docs/runtime_protocol.md §5.2);客户端 offset < window_start
即事件已被裁剪,须走重新同步(会话快照对账)而非续传。
"""
from server.tasks import task_manager
rec = task_manager.get_task(username, task_id)
if not rec:
return None, None, tr("tasks.task_not_found"), None
offset = max(0, int(offset or 0))
events = task_manager.get_events_since(rec, offset)
next_offset = events[-1]["idx"] + 1 if events else offset
meta = {"window_start": task_manager.get_event_window_start(rec)}
return events, next_offset, None, meta
# 进程级单例(无状态,可安全共享)
runtime_service = RuntimeService()