- task event stream (_append_event) is now the sole realtime channel for web and CLI - status snapshots: idle 5s polling of /api/status (was status_update push); operation initiator gets state from REST responses - terminal panel: REST polling (list 5s, output 1.5s, prefix-matched incremental xterm writes) - multi-tab passive sync deliberately degrades to polling-based perception - delete socket_handlers/broadcast/useLegacySocket (2103-line dead file); extensions.py keeps only run_background - remove socket token chain (/api/socket-token, prune/consume_socket_token, pending_socket_tokens) - app.run(threaded=True) replaces socketio.run; reapers switched to plain threading - drop flask-socketio/socket.io-client/websockets dependencies - docs: AGENTS.md section 12.5 (replacement map + hard constraints), plus CLI rewrite companion doc updates Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
1187 lines
55 KiB
Python
1187 lines
55 KiB
Python
"""简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
|
||
from __future__ import annotations
|
||
import mimetypes
|
||
import json
|
||
import time
|
||
import threading
|
||
import uuid
|
||
import re
|
||
from collections import deque
|
||
from pathlib import Path
|
||
from typing import Dict, Any, Optional, List
|
||
|
||
# 注意:本模块属于任务核心层,禁止顶层 import Web 路由层模块
|
||
# (server.tasks.blueprint / server.chat_flow 等),保证无 Web 应用初始化时可独立加载。
|
||
# run_chat_task_sync 在使用点函数内延迟导入(见 _run_chat_task)。
|
||
from server.context import RuntimeIdentity, get_user_resources, ensure_conversation_loaded
|
||
from server.main_task_gate import release_main_task_gate
|
||
from server.runtime.context import InternalDirectives, RuntimeContext, TaskParams, TrustedPrincipal
|
||
from server.work_timer import finalize_conversation_work_timer
|
||
from server.state import stop_flags
|
||
from server.utils_common import debug_log, log_conn_diag
|
||
from utils.host_workspace_debug import write_host_workspace_debug
|
||
from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME
|
||
from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL
|
||
from modules.sub_agent.state import TERMINAL_STATUSES as SUB_AGENT_TERMINAL_STATUSES
|
||
from modules.background_command_manager import BackgroundCommandManager, TERMINAL_STATUSES as BG_COMMAND_TERMINAL_STATUSES
|
||
from modules.i18n import tr
|
||
|
||
|
||
SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S)
|
||
SKILL_FIELD_RE = re.compile(r"^(?P<key>name|description)\s*:\s*(?P<value>.*)$")
|
||
|
||
|
||
|
||
class TaskRecord:
|
||
__slots__ = (
|
||
"task_id",
|
||
"username",
|
||
"workspace_id",
|
||
"status",
|
||
"created_at",
|
||
"updated_at",
|
||
"message",
|
||
"conversation_id",
|
||
"events",
|
||
"thread",
|
||
"error",
|
||
"model_key",
|
||
"thinking_mode",
|
||
"run_mode",
|
||
"max_iterations",
|
||
# 三层结构化运行上下文(契约 docs/runtime_contract.md §4.1):受理时由
|
||
# RuntimeService.create_task 传入 RuntimeContext 并固化,任务线程直接
|
||
# 按层读取——不再有 session_data 兼容快照 dict。
|
||
"principal",
|
||
"task_params",
|
||
"directives",
|
||
# 运行期可变回写:goal_progress 事件最新快照(启动时为空,随事件流更新)
|
||
"goal_progress",
|
||
"stop_requested",
|
||
"next_event_idx",
|
||
"runtime_pending_queue",
|
||
"runtime_guidance_queue",
|
||
"last_cancel_at",
|
||
"task_type",
|
||
)
|
||
|
||
def __init__(
|
||
self,
|
||
task_id: str,
|
||
ctx: RuntimeContext,
|
||
conversation_id: Optional[str],
|
||
):
|
||
principal = ctx.principal
|
||
params = ctx.params
|
||
self.task_id = task_id
|
||
self.username = principal.username
|
||
self.workspace_id = principal.workspace_id
|
||
self.status = "pending"
|
||
self.created_at = time.time()
|
||
self.updated_at = self.created_at
|
||
self.message = params.message
|
||
# conversation_id 经受理层补建兜底后显式传入(可能与 params.conversation_id 不同)
|
||
self.conversation_id = conversation_id
|
||
# 刷新恢复时前端会从事件流重建进行中的输出,1000 在长流式回复下会过早截断,
|
||
# 导致“只恢复最后几个字符”。这里提高缓冲上限,优先保证重建完整性。
|
||
self.events: deque[Dict[str, Any]] = deque(maxlen=20000)
|
||
self.thread: Optional[threading.Thread] = None
|
||
self.error: Optional[str] = None
|
||
self.model_key = params.model_key
|
||
self.thinking_mode = params.thinking_mode
|
||
self.run_mode = params.run_mode
|
||
self.max_iterations = params.max_iterations
|
||
self.principal: TrustedPrincipal = principal
|
||
self.task_params: TaskParams = params
|
||
self.directives: InternalDirectives = ctx.directives or InternalDirectives()
|
||
self.goal_progress: Optional[Dict[str, Any]] = None
|
||
self.stop_requested: bool = False
|
||
self.next_event_idx: int = 0
|
||
self.runtime_pending_queue: List[Dict[str, Any]] = []
|
||
self.runtime_guidance_queue: List[str] = []
|
||
self.last_cancel_at: Optional[float] = None
|
||
self.task_type = str(params.task_type or "chat")
|
||
|
||
class TaskManager:
|
||
"""线程内存版任务管理器,后续可替换为 Redis/DB。"""
|
||
|
||
def __init__(self):
|
||
self._tasks: Dict[str, TaskRecord] = {}
|
||
self._lock = threading.Lock()
|
||
|
||
def cleanup_old_tasks(self, max_age_seconds: int = 3600) -> int:
|
||
"""清理超过指定时间的已完成/已停止任务。
|
||
|
||
终态集合说明:用户取消的实际终态是 "stopped"(历史代码误写为从未被赋值的
|
||
"canceled",导致 stopped 任务永不清理,此处修正并保留 canceled 兼容)。
|
||
cancel_requested 正常是 cancel_task 到收尾之间的秒级瞬态;若因进程异常残留
|
||
超过 max_age,必为死记录,一并清理兜底(updated_at 在打标时已刷新,正常
|
||
收尾中的任务不可能存活到 max_age)。
|
||
"""
|
||
now = time.time()
|
||
with self._lock:
|
||
to_remove = []
|
||
for task_id, rec in self._tasks.items():
|
||
if rec.status in {"succeeded", "failed", "stopped", "canceled", "cancel_requested"}:
|
||
age = now - rec.updated_at
|
||
if age > max_age_seconds:
|
||
to_remove.append(task_id)
|
||
|
||
for task_id in to_remove:
|
||
del self._tasks[task_id]
|
||
|
||
return len(to_remove)
|
||
|
||
# ---- public APIs ----
|
||
def create_chat_task(
|
||
self,
|
||
ctx: RuntimeContext,
|
||
conversation_id: Optional[str] = None,
|
||
) -> TaskRecord:
|
||
"""受理一轮 Run:显式三层上下文 → 互斥裁决 → 登记 → 起执行线程。
|
||
|
||
契约 docs/runtime_contract.md §4.1:``ctx`` 是唯一上下文来源(身份/参数/
|
||
内部指令三层),禁止隐式读取 Flask session。``conversation_id`` 允许覆盖
|
||
params 中的值(受理层补建对话后传入),未传时取 params.conversation_id。
|
||
"""
|
||
if ctx is None:
|
||
raise ValueError(tr("tasks.missing_session_data"))
|
||
ctx.validate()
|
||
principal = ctx.principal
|
||
params = ctx.params
|
||
username = principal.username
|
||
workspace_id = principal.workspace_id
|
||
run_mode = params.run_mode
|
||
if run_mode:
|
||
normalized = str(run_mode).lower()
|
||
if normalized not in {"fast", "thinking", "deep"}:
|
||
raise ValueError(tr("tasks.invalid_run_mode"))
|
||
run_mode = normalized
|
||
if conversation_id is None:
|
||
conversation_id = params.conversation_id
|
||
normalized_task_type = str(params.task_type or "chat").strip().lower() or "chat"
|
||
# 单对话互斥:普通 chat 任务禁止同一对话并发(防串写对话历史);
|
||
# 同工作区不同对话允许并行(对话级 terminal 隔离)。
|
||
# notice(通知触发)任务允许与已完成的 chat 任务共存,用于后台通知重入。
|
||
if normalized_task_type == "chat":
|
||
def _norm_cid(cid):
|
||
cid = str(cid or "").strip()
|
||
return cid[5:] if cid.startswith("conv_") else cid
|
||
target_cid = _norm_cid(conversation_id)
|
||
existing = [
|
||
t for t in self.list_tasks(username, workspace_id)
|
||
if t.status in {"pending", "running"}
|
||
and getattr(t, "task_type", "chat") == "chat"
|
||
and _norm_cid(getattr(t, "conversation_id", None)) == target_cid
|
||
]
|
||
if existing:
|
||
raise RuntimeError(tr("tasks.task_already_running"))
|
||
task_id = str(uuid.uuid4())
|
||
record = TaskRecord(task_id, ctx, conversation_id)
|
||
record.task_type = normalized_task_type
|
||
with self._lock:
|
||
self._tasks[task_id] = record
|
||
thread = threading.Thread(
|
||
target=self._run_chat_task,
|
||
args=(record, list(params.images or []), list(params.videos or []), list(params.files or [])),
|
||
daemon=True,
|
||
)
|
||
record.thread = thread
|
||
record.status = "running"
|
||
record.updated_at = time.time()
|
||
thread.start()
|
||
return record
|
||
|
||
def get_task(self, username: str, task_id: str) -> Optional[TaskRecord]:
|
||
with self._lock:
|
||
rec = self._tasks.get(task_id)
|
||
if not rec or rec.username != username:
|
||
return None
|
||
return rec
|
||
|
||
def get_events_since(self, rec: TaskRecord, offset: int) -> List[Dict[str, Any]]:
|
||
"""按 offset 过滤事件。
|
||
|
||
rec.events 由任务工作线程持续追加(流式输出期间非常频繁),
|
||
直接迭代会在并发追加时抛 RuntimeError: deque mutated during iteration。
|
||
先在锁内做 O(n) 浅拷贝快照,再在锁外过滤。
|
||
"""
|
||
with self._lock:
|
||
snapshot = list(rec.events)
|
||
return [e for e in snapshot if e["idx"] >= offset]
|
||
|
||
def get_event_window_start(self, rec: TaskRecord) -> int:
|
||
"""事件窗口当前最小 idx(缺口检测水位,协议 docs/runtime_protocol.md §5.2)。
|
||
|
||
deque(maxlen) 挤出旧事件后窗口前移;客户端 offset 小于该值即说明
|
||
中间事件已被裁剪,必须走重新同步(会话快照对账)而非续传。
|
||
idx 单调不回绕,窗口未裁剪时为 0。
|
||
"""
|
||
with self._lock:
|
||
return rec.events[0]["idx"] if rec.events else 0
|
||
|
||
def list_tasks(self, username: str, workspace_id: Optional[str] = None) -> List[TaskRecord]:
|
||
with self._lock:
|
||
return [
|
||
rec
|
||
for rec in self._tasks.values()
|
||
if rec.username == username and (workspace_id is None or rec.workspace_id == workspace_id)
|
||
]
|
||
|
||
def cancel_task(self, username: str, task_id: str) -> bool:
|
||
"""取消主智能体任务。只停主智能体,不触碰后台任务。
|
||
|
||
后台任务(子智能体、后台指令)的终止/暂停由独立的 API 处理,
|
||
参见 /api/sub_agents/stop_all 和 /api/background_commands/stop_all。
|
||
"""
|
||
rec = self.get_task(username, task_id)
|
||
if not rec:
|
||
debug_log(f"[TaskCancel] cancel_task 找不到任务: task_id={task_id}")
|
||
return False
|
||
|
||
task_id = rec.task_id
|
||
status_at_entry = rec.status
|
||
|
||
# 取终端引用(仅用于停止目标模式等副作用)
|
||
entry = stop_flags.get(task_id)
|
||
terminal = None
|
||
if isinstance(entry, dict):
|
||
terminal = entry.get('terminal')
|
||
if not terminal and rec.workspace_id:
|
||
try:
|
||
terminal, _ = get_user_resources(username, rec.workspace_id, conversation_id=rec.conversation_id)
|
||
except Exception:
|
||
pass
|
||
|
||
debug_log(
|
||
f"[TaskCancel] 入口: task_id={task_id}, status={status_at_entry}, "
|
||
f"conv={rec.conversation_id}"
|
||
)
|
||
|
||
# 1. 硬取消主 asyncio task(如果引用还在)
|
||
if isinstance(entry, dict):
|
||
loop = entry.get('loop')
|
||
task = entry.get('task')
|
||
if loop and task and not task.done():
|
||
try:
|
||
loop.call_soon_threadsafe(task.cancel)
|
||
debug_log(f"[TaskCancel] 已投递硬取消: task_id={task_id}")
|
||
except Exception as exc:
|
||
debug_log(f"[TaskCancel] 硬取消失败: task_id={task_id}, error={exc}")
|
||
entry['stop'] = True
|
||
else:
|
||
stop_flags[task_id] = {'stop': True, 'task': None, 'terminal': None, 'loop': None}
|
||
entry = stop_flags[task_id]
|
||
|
||
rec.stop_requested = True
|
||
|
||
# 2. 停止目标模式
|
||
if rec.workspace_id:
|
||
try:
|
||
_, workspace = get_user_resources(username, rec.workspace_id, conversation_id=rec.conversation_id)
|
||
if workspace and rec.conversation_id:
|
||
gsm = GoalStateManager(workspace.data_dir, rec.conversation_id)
|
||
if gsm.is_active():
|
||
gsm.mark_stopped(REASON_USER_CANCEL)
|
||
debug_log(f"[Goal] 用户取消任务 {task_id},同步停止本对话目标模式")
|
||
except Exception as exc:
|
||
debug_log(f"[Goal] 取消任务时停止目标模式失败: {exc}")
|
||
|
||
# 3. 丢弃已经引导的内容(预输入队列保持不变,正常结束后再插入)
|
||
with self._lock:
|
||
rec.runtime_guidance_queue = []
|
||
rec.updated_at = time.time()
|
||
|
||
# 4. 标记为 cancel_requested。
|
||
# _run_chat_task finally 会并发把 status 改为 stopped 并发 task_stopped 事件。
|
||
# 此处仅做软标记,不强制覆盖 finally 即将设的终态。
|
||
now = time.time()
|
||
with self._lock:
|
||
if rec.status in {"running", "pending"}:
|
||
rec.status = "cancel_requested"
|
||
rec.updated_at = now
|
||
rec.last_cancel_at = now
|
||
debug_log(
|
||
f"[TaskCancel] 已取消主智能体: task_id={task_id}, "
|
||
f"status_at_entry={status_at_entry}, current_status={rec.status}"
|
||
)
|
||
return True
|
||
|
||
@staticmethod
|
||
def _normalize_runtime_pending_queue(raw_queue: Any) -> List[Dict[str, Any]]:
|
||
now_ts = time.time()
|
||
normalized: List[Dict[str, Any]] = []
|
||
if not isinstance(raw_queue, list):
|
||
return normalized
|
||
for raw_item in raw_queue:
|
||
if isinstance(raw_item, dict):
|
||
item_id = str(raw_item.get("id") or "").strip()
|
||
text = str(raw_item.get("text") or "").strip()
|
||
created_at = raw_item.get("created_at")
|
||
raw_files = raw_item.get("files")
|
||
else:
|
||
item_id = ""
|
||
text = str(raw_item or "").strip()
|
||
created_at = None
|
||
raw_files = None
|
||
if not text:
|
||
continue
|
||
if not item_id:
|
||
item_id = str(uuid.uuid4())
|
||
try:
|
||
created_at_float = float(created_at)
|
||
except Exception:
|
||
created_at_float = now_ts
|
||
entry = {
|
||
"id": item_id,
|
||
"text": text,
|
||
"created_at": created_at_float,
|
||
}
|
||
if isinstance(raw_files, list):
|
||
files = [
|
||
str(p).strip()
|
||
for p in raw_files
|
||
if isinstance(p, str) and str(p).strip()
|
||
][:9]
|
||
if files:
|
||
entry["files"] = files
|
||
normalized.append(entry)
|
||
return normalized
|
||
|
||
@staticmethod
|
||
def _runtime_pending_public(queue: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
result: List[Dict[str, Any]] = []
|
||
for item in queue or []:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
text = str(item.get("text") or "").strip()
|
||
item_id = str(item.get("id") or "").strip()
|
||
if not text or not item_id:
|
||
continue
|
||
entry = {
|
||
"id": item_id,
|
||
"text": text,
|
||
"created_at": item.get("created_at"),
|
||
}
|
||
if isinstance(item.get("files"), list) and item["files"]:
|
||
entry["files"] = list(item["files"])
|
||
result.append(entry)
|
||
return result
|
||
|
||
def enqueue_runtime_pending_message(
|
||
self,
|
||
username: str,
|
||
task_id: str,
|
||
message: str,
|
||
max_queue_size: int = 5,
|
||
files: Optional[List[str]] = None,
|
||
) -> Dict[str, Any]:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return {"success": False, "code": "empty_message", "error": tr("tasks.message_empty")}
|
||
limit = int(max(1, max_queue_size))
|
||
with self._lock:
|
||
rec = self._tasks.get(task_id)
|
||
if not rec or rec.username != username:
|
||
return {"success": False, "code": "task_not_found", "error": tr("tasks.task_not_found")}
|
||
if rec.status not in {"pending", "running", "cancel_requested"}:
|
||
return {"success": False, "code": "task_not_running", "error": tr("tasks.task_not_running_append_message")}
|
||
queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None))
|
||
if len(queue) >= limit:
|
||
return {
|
||
"success": False,
|
||
"code": "queue_full",
|
||
"error": tr("tasks.queue_full_max", limit=limit),
|
||
}
|
||
item = {
|
||
"id": str(uuid.uuid4()),
|
||
"text": text,
|
||
"created_at": time.time(),
|
||
}
|
||
if isinstance(files, list):
|
||
normalized_files = [
|
||
str(p).strip() for p in files if isinstance(p, str) and str(p).strip()
|
||
][:9]
|
||
if normalized_files:
|
||
item["files"] = normalized_files
|
||
queue.append(item)
|
||
rec.runtime_pending_queue = queue
|
||
rec.updated_at = time.time()
|
||
return {
|
||
"success": True,
|
||
"task_id": rec.task_id,
|
||
"item": item,
|
||
"messages": self._runtime_pending_public(queue),
|
||
}
|
||
|
||
def remove_runtime_pending_message(
|
||
self, username: str, task_id: str, message_id: str
|
||
) -> Dict[str, Any]:
|
||
target_id = str(message_id or "").strip()
|
||
if not target_id:
|
||
return {"success": False, "code": "invalid_message_id", "error": tr("tasks.invalid_message_id")}
|
||
with self._lock:
|
||
rec = self._tasks.get(task_id)
|
||
if not rec or rec.username != username:
|
||
return {"success": False, "code": "task_not_found", "error": tr("tasks.task_not_found")}
|
||
queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None))
|
||
remove_idx = -1
|
||
for idx, item in enumerate(queue):
|
||
if str(item.get("id") or "") == target_id:
|
||
remove_idx = idx
|
||
break
|
||
if remove_idx < 0:
|
||
return {"success": False, "code": "message_not_found", "error": tr("tasks.message_not_found")}
|
||
queue.pop(remove_idx)
|
||
rec.runtime_pending_queue = queue
|
||
rec.updated_at = time.time()
|
||
return {
|
||
"success": True,
|
||
"task_id": rec.task_id,
|
||
"messages": self._runtime_pending_public(queue),
|
||
}
|
||
|
||
def promote_runtime_pending_to_guidance(
|
||
self,
|
||
username: str,
|
||
task_id: str,
|
||
message_id: str,
|
||
max_guidance_queue_size: int = 5,
|
||
) -> Dict[str, Any]:
|
||
target_id = str(message_id or "").strip()
|
||
if not target_id:
|
||
return {"success": False, "code": "invalid_message_id", "error": tr("tasks.invalid_message_id")}
|
||
guidance_limit = int(max(1, max_guidance_queue_size))
|
||
with self._lock:
|
||
rec = self._tasks.get(task_id)
|
||
if not rec or rec.username != username:
|
||
return {"success": False, "code": "task_not_found", "error": tr("tasks.task_not_found")}
|
||
if rec.status not in {"pending", "running", "cancel_requested"}:
|
||
return {"success": False, "code": "task_not_running", "error": tr("tasks.task_not_running_guide")}
|
||
queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None))
|
||
guidance_queue = getattr(rec, "runtime_guidance_queue", None)
|
||
if not isinstance(guidance_queue, list):
|
||
guidance_queue = []
|
||
if len(guidance_queue) >= guidance_limit:
|
||
return {
|
||
"success": False,
|
||
"code": "guidance_queue_full",
|
||
"error": tr("tasks.guidance_queue_full_max", guidance_limit=guidance_limit),
|
||
}
|
||
selected = None
|
||
remain_queue: List[Dict[str, Any]] = []
|
||
for item in queue:
|
||
if selected is None and str(item.get("id") or "") == target_id:
|
||
selected = item
|
||
continue
|
||
remain_queue.append(item)
|
||
if not selected:
|
||
return {"success": False, "code": "message_not_found", "error": tr("tasks.message_not_found")}
|
||
selected_text = str(selected.get("text") or "").strip()
|
||
if not selected_text:
|
||
return {"success": False, "code": "empty_message", "error": tr("tasks.message_content_empty")}
|
||
selected_files = selected.get("files")
|
||
if isinstance(selected_files, list) and selected_files:
|
||
guidance_queue.append({"text": selected_text, "files": list(selected_files)[:9]})
|
||
else:
|
||
guidance_queue.append(selected_text)
|
||
rec.runtime_guidance_queue = guidance_queue
|
||
rec.runtime_pending_queue = remain_queue
|
||
rec.updated_at = time.time()
|
||
return {
|
||
"success": True,
|
||
"task_id": rec.task_id,
|
||
"queued_count": len(guidance_queue),
|
||
"messages": self._runtime_pending_public(remain_queue),
|
||
}
|
||
|
||
def get_runtime_pending_messages(self, username: str, task_id: str) -> List[Dict[str, Any]]:
|
||
with self._lock:
|
||
rec = self._tasks.get(task_id)
|
||
if not rec or rec.username != username:
|
||
return []
|
||
queue = self._normalize_runtime_pending_queue(getattr(rec, "runtime_pending_queue", None))
|
||
rec.runtime_pending_queue = queue
|
||
return self._runtime_pending_public(queue)
|
||
|
||
def enqueue_runtime_guidance(
|
||
self,
|
||
username: str,
|
||
task_id: str,
|
||
message: str,
|
||
max_queue_size: int = 5,
|
||
source: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return {"success": False, "code": "empty_message", "error": tr("tasks.guidance_content_empty")}
|
||
with self._lock:
|
||
rec = self._tasks.get(task_id)
|
||
if not rec or rec.username != username:
|
||
return {"success": False, "code": "task_not_found", "error": tr("tasks.task_not_found")}
|
||
if rec.status not in {"pending", "running", "cancel_requested"}:
|
||
return {"success": False, "code": "task_not_running", "error": tr("tasks.task_not_running_append_guidance")}
|
||
queue = getattr(rec, "runtime_guidance_queue", None)
|
||
if not isinstance(queue, list):
|
||
queue = []
|
||
rec.runtime_guidance_queue = queue
|
||
if len(queue) >= int(max(1, max_queue_size)):
|
||
return {
|
||
"success": False,
|
||
"code": "queue_full",
|
||
"error": tr("tasks.guidance_queue_full_max", guidance_limit=int(max(1, max_queue_size))),
|
||
}
|
||
normalized_source = str(source or "").strip().lower()
|
||
if normalized_source:
|
||
queue.append({"text": text, "source": normalized_source})
|
||
else:
|
||
queue.append(text)
|
||
rec.updated_at = time.time()
|
||
return {
|
||
"success": True,
|
||
"queued_count": len(queue),
|
||
"task_id": rec.task_id,
|
||
}
|
||
|
||
def pop_runtime_guidance_for_injection(self, username: str, task_id: str) -> Optional[Any]:
|
||
with self._lock:
|
||
rec = self._tasks.get(task_id)
|
||
if not rec or rec.username != username:
|
||
return None
|
||
queue = getattr(rec, "runtime_guidance_queue", None)
|
||
if not isinstance(queue, list) or not queue:
|
||
return None
|
||
item = queue.pop(0)
|
||
rec.updated_at = time.time()
|
||
if isinstance(item, dict):
|
||
text = str(item.get("text") or "").strip()
|
||
if not text:
|
||
return None
|
||
src = str(item.get("source") or "").strip().lower()
|
||
return {"text": text, "source": src} if src else {"text": text}
|
||
text = str(item or "").strip()
|
||
return text or None
|
||
|
||
def consume_runtime_guidance_messages(self, username: str, task_id: str) -> List[str]:
|
||
with self._lock:
|
||
rec = self._tasks.get(task_id)
|
||
if not rec or rec.username != username:
|
||
return []
|
||
queue = getattr(rec, "runtime_guidance_queue", None)
|
||
if not isinstance(queue, list) or not queue:
|
||
return []
|
||
items: List[str] = []
|
||
for item in queue:
|
||
if isinstance(item, dict):
|
||
text = str(item.get("text") or "").strip()
|
||
else:
|
||
text = str(item or "").strip()
|
||
if text:
|
||
items.append(text)
|
||
rec.runtime_guidance_queue = []
|
||
rec.updated_at = time.time()
|
||
return items
|
||
|
||
def consume_runtime_guidance_for_injection(self, username: str, task_id: str) -> List[Any]:
|
||
"""按原始结构取出整批引导/通知消息(支持 str 与 {text,source})。"""
|
||
with self._lock:
|
||
rec = self._tasks.get(task_id)
|
||
if not rec or rec.username != username:
|
||
return []
|
||
queue = getattr(rec, "runtime_guidance_queue", None)
|
||
if not isinstance(queue, list) or not queue:
|
||
return []
|
||
items: List[Any] = []
|
||
for item in queue:
|
||
if isinstance(item, dict):
|
||
text = str(item.get("text") or "").strip()
|
||
if not text:
|
||
continue
|
||
src = str(item.get("source") or "").strip().lower()
|
||
entry = {"text": text, "source": src} if src else {"text": text}
|
||
raw_files = item.get("files")
|
||
if isinstance(raw_files, list) and raw_files:
|
||
entry["files"] = [
|
||
str(p).strip()
|
||
for p in raw_files
|
||
if isinstance(p, str) and str(p).strip()
|
||
][:9]
|
||
items.append(entry)
|
||
else:
|
||
text = str(item or "").strip()
|
||
if text:
|
||
items.append(text)
|
||
rec.runtime_guidance_queue = []
|
||
rec.updated_at = time.time()
|
||
return items
|
||
|
||
# ---- internal helpers ----
|
||
def _cleanup_background_tasks(self, rec: TaskRecord, terminal: Optional[Any]) -> bool:
|
||
"""清理指定任务/对话下的所有后台子智能体和后台命令,返回是否清理到任何任务。"""
|
||
has_running_background = False
|
||
if not terminal or not rec.conversation_id:
|
||
return has_running_background
|
||
|
||
sub_agent_manager = getattr(terminal, 'sub_agent_manager', None)
|
||
if sub_agent_manager:
|
||
try:
|
||
sub_agent_manager.reconcile_task_states(conversation_id=rec.conversation_id)
|
||
for task_info in list(sub_agent_manager.tasks.values()):
|
||
if task_info.get('conversation_id') != rec.conversation_id:
|
||
continue
|
||
status = task_info.get('status')
|
||
if status not in SUB_AGENT_TERMINAL_STATUSES.union({"terminated"}):
|
||
has_running_background = True
|
||
try:
|
||
sub_agent_manager.terminate_sub_agent(task_id=task_info.get('task_id'))
|
||
except Exception as exc:
|
||
debug_log(f"[TaskCancel] 终止子智能体失败: {exc}")
|
||
except Exception as exc:
|
||
debug_log(f"[TaskCancel] 检查后台子智能体失败: {exc}")
|
||
|
||
bg_manager = getattr(terminal, 'background_command_manager', None)
|
||
if bg_manager:
|
||
try:
|
||
bg_manager.reconcile_stale_records(conversation_id=rec.conversation_id)
|
||
waiting_items = bg_manager.list_waiting_items(rec.conversation_id)
|
||
for item in waiting_items:
|
||
has_running_background = True
|
||
try:
|
||
bg_manager.cancel_command(item.get('command_id'))
|
||
except Exception as exc:
|
||
debug_log(f"[TaskCancel] 取消后台命令失败: {exc}")
|
||
# 把该对话下所有未通知的终态记录也标为已通知,避免后续幽灵通知
|
||
try:
|
||
with bg_manager._lock:
|
||
for record in bg_manager._records.values():
|
||
if record.get('conversation_id') != rec.conversation_id:
|
||
continue
|
||
if record.get('status') in BG_COMMAND_TERMINAL_STATUSES and not record.get('notified'):
|
||
record['notified'] = True
|
||
record['updated_at'] = time.time()
|
||
except Exception as exc:
|
||
debug_log(f"[TaskCancel] 标记后台通知状态失败: {exc}")
|
||
except Exception as exc:
|
||
debug_log(f"[TaskCancel] 检查后台命令失败: {exc}")
|
||
|
||
return has_running_background
|
||
|
||
@staticmethod
|
||
def get_conversation_running_status(terminal: Optional[Any], conversation_id: Optional[str]) -> Dict[str, bool]:
|
||
"""按对话聚合后台运行状态(REST 对账接口用)。
|
||
|
||
返回三类后台工作,语义互不重叠:
|
||
- has_running_sub_agents: 传统后台子智能体(排除多智能体实例任务)
|
||
- has_running_background_commands: 后台命令(含终态未通知)
|
||
- has_running_multi_agent: 多智能体实例在跑、非终态多智能体任务、
|
||
或有待消费的 pending master 消息
|
||
"""
|
||
result = {
|
||
"has_running_sub_agents": False,
|
||
"has_running_background_commands": False,
|
||
"has_running_multi_agent": False,
|
||
}
|
||
if not terminal or not conversation_id:
|
||
return result
|
||
|
||
sub_agent_manager = getattr(terminal, 'sub_agent_manager', None)
|
||
if sub_agent_manager:
|
||
try:
|
||
sub_agent_manager.reconcile_task_states(conversation_id=conversation_id)
|
||
terminal_statuses = SUB_AGENT_TERMINAL_STATUSES.union({"terminated"})
|
||
for task_info in sub_agent_manager.tasks.values():
|
||
if task_info.get('conversation_id') != conversation_id:
|
||
continue
|
||
status = task_info.get('status')
|
||
if status in terminal_statuses:
|
||
continue
|
||
if task_info.get('multi_agent_mode'):
|
||
# idle = 本轮输出结束、保留上下文等待指令,不算运行中。
|
||
# 对齐 socket task_complete 语义(chat_flow_task_main.py:
|
||
# 仅 running 实例 + pending master 消息算活跃),否则
|
||
# REST 对账会把已空闲的多智能体对话误判为运行中(前端
|
||
# 幽灵轮询),且回收器永远不敢回收该对话的 terminal。
|
||
if status == "idle":
|
||
continue
|
||
result["has_running_multi_agent"] = True
|
||
else:
|
||
result["has_running_sub_agents"] = True
|
||
except Exception as exc:
|
||
debug_log(f"[Task] 对账检查子智能体失败: {exc}")
|
||
try:
|
||
state = sub_agent_manager.get_multi_agent_state(conversation_id)
|
||
if state:
|
||
has_running_instance = any(a.status == "running" for a in state.list_all())
|
||
if has_running_instance or state.has_pending_master_messages():
|
||
result["has_running_multi_agent"] = True
|
||
except Exception as exc:
|
||
debug_log(f"[Task] 对账检查多智能体状态失败: {exc}")
|
||
|
||
bg_manager = getattr(terminal, 'background_command_manager', None)
|
||
if bg_manager:
|
||
try:
|
||
bg_manager.reconcile_stale_records(conversation_id=conversation_id)
|
||
waiting_items = bg_manager.list_waiting_items(conversation_id)
|
||
if waiting_items:
|
||
result["has_running_background_commands"] = True
|
||
except Exception as exc:
|
||
debug_log(f"[Task] 对账检查后台命令失败: {exc}")
|
||
|
||
return result
|
||
|
||
@staticmethod
|
||
def _has_running_background(rec: TaskRecord, terminal: Optional[Any]) -> Dict[str, bool]:
|
||
"""检测指定任务/对话是否还有运行中的后台子智能体或后台命令。
|
||
|
||
兼容旧语义:多智能体实例任务也计入 sub_agents。
|
||
"""
|
||
result = {"has_running_sub_agents": False, "has_running_background_commands": False}
|
||
if not rec:
|
||
return result
|
||
status = TaskManager.get_conversation_running_status(terminal, rec.conversation_id)
|
||
result["has_running_sub_agents"] = (
|
||
status["has_running_sub_agents"] or status["has_running_multi_agent"]
|
||
)
|
||
result["has_running_background_commands"] = status["has_running_background_commands"]
|
||
return result
|
||
|
||
def _append_event(self, rec: TaskRecord, event_type: str, data: Dict[str, Any]):
|
||
if isinstance(data, dict):
|
||
data = dict(data)
|
||
data.setdefault("task_id", rec.task_id)
|
||
if rec.conversation_id:
|
||
data.setdefault("conversation_id", rec.conversation_id)
|
||
if rec.workspace_id:
|
||
data.setdefault("workspace_id", rec.workspace_id)
|
||
with self._lock:
|
||
if event_type in {"goal_progress", "goal_completed", "goal_stopped"} and isinstance(data, dict):
|
||
rec.goal_progress = dict(data)
|
||
idx = getattr(rec, "next_event_idx", None)
|
||
if idx is None:
|
||
idx = rec.events[-1]["idx"] + 1 if rec.events else 0
|
||
rec.next_event_idx = idx + 1
|
||
rec.events.append({
|
||
"idx": idx,
|
||
"type": event_type,
|
||
"data": data,
|
||
"ts": time.time(),
|
||
})
|
||
rec.updated_at = time.time()
|
||
|
||
def _run_chat_task(self, rec: TaskRecord, images: List[Any], videos: List[Any], files: Optional[List[str]] = None):
|
||
username = rec.username
|
||
workspace_id = rec.workspace_id
|
||
terminal = None
|
||
workspace = None
|
||
stop_hint = False
|
||
try:
|
||
# 显式运行上下文(契约 docs/runtime_contract.md §4.1):身份与偏好快照
|
||
# 受理时已固化为 rec.principal(TrustedPrincipal),这里直接映射为
|
||
# RuntimeIdentity 驱动资源装配——任务线程全程无隐式上下文。
|
||
p = rec.principal
|
||
identity = RuntimeIdentity(
|
||
host_mode=bool(p.host_mode),
|
||
host_workspace_id=p.host_workspace_id,
|
||
is_api_user=bool(p.is_api_user),
|
||
role=p.role,
|
||
preferred_model_key=p.preferred_model_key,
|
||
preferred_run_mode=p.preferred_run_mode,
|
||
preferred_thinking_mode=p.preferred_thinking_mode,
|
||
)
|
||
if identity.host_mode:
|
||
write_host_workspace_debug(
|
||
"tasks.run_chat_task.apply_host_session",
|
||
task_id=rec.task_id,
|
||
workspace_id=workspace_id,
|
||
host_workspace_id=identity.host_workspace_id or workspace_id,
|
||
)
|
||
terminal, workspace = get_user_resources(
|
||
username,
|
||
workspace_id=workspace_id,
|
||
update_session=False,
|
||
conversation_id=rec.conversation_id,
|
||
identity=identity,
|
||
)
|
||
if not terminal or not workspace:
|
||
raise RuntimeError(tr("tasks.system_not_initialized"))
|
||
stop_hint = bool(stop_flags.get(rec.task_id, {}).get("stop"))
|
||
|
||
def _apply_requested_model_mode():
|
||
# API 传入的模型/模式配置
|
||
if rec.model_key:
|
||
try:
|
||
terminal.set_model(rec.model_key)
|
||
except Exception as exc:
|
||
debug_log(f"[Task] 设置模型失败 {rec.model_key}: {exc}")
|
||
if rec.run_mode:
|
||
try:
|
||
terminal.set_run_mode(rec.run_mode)
|
||
except Exception as exc:
|
||
debug_log(f"[Task] 设置运行模式失败 {rec.run_mode}: {exc}")
|
||
elif rec.thinking_mode is not None:
|
||
try:
|
||
terminal.set_run_mode("thinking" if rec.thinking_mode else "fast")
|
||
except Exception as exc:
|
||
debug_log(f"[Task] 设置思考模式失败: {exc}")
|
||
|
||
_apply_requested_model_mode()
|
||
if rec.max_iterations:
|
||
try:
|
||
terminal.max_iterations_override = int(rec.max_iterations)
|
||
except Exception:
|
||
terminal.max_iterations_override = None
|
||
try:
|
||
debug_log(
|
||
"[Task] effective terminal state "
|
||
f"model_key={getattr(terminal, 'model_key', None)!r} "
|
||
f"run_mode={getattr(terminal, 'run_mode', None)!r} "
|
||
f"thinking_mode={getattr(terminal, 'thinking_mode', None)!r} "
|
||
f"reasoning_effort={getattr(terminal, 'reasoning_effort', None)!r}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# 确保会话加载
|
||
conversation_id = rec.conversation_id
|
||
try:
|
||
conversation_id, _ = ensure_conversation_loaded(terminal, conversation_id, workspace=workspace, update_session=False)
|
||
rec.conversation_id = conversation_id
|
||
except Exception as exc:
|
||
raise RuntimeError(tr("tasks.conversation_load_failed", error=exc)) from exc
|
||
|
||
# 对话加载会按会话元数据恢复历史模型/模式,这里再覆盖一次用户本次请求参数
|
||
_apply_requested_model_mode()
|
||
try:
|
||
debug_log(
|
||
"[Task] post-conversation effective state "
|
||
f"model_key={getattr(terminal, 'model_key', None)!r} "
|
||
f"run_mode={getattr(terminal, 'run_mode', None)!r} "
|
||
f"thinking_mode={getattr(terminal, 'thinking_mode', None)!r} "
|
||
f"reasoning_effort={getattr(terminal, 'reasoning_effort', None)!r}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# 仅对“后台通知触发的新任务”补发 user_message 事件到任务事件流。
|
||
# 这样前端轮询能即时看到这条 user 消息,而不是刷新后才从历史中看到。
|
||
try:
|
||
if rec.directives.auto_user_message_event:
|
||
# 先回放本批「通知池」里的前置完成通知(除触发消息外的 N-1 条),
|
||
# 保证轮询客户端按时间顺序看到所有完成通知,且不各自触发新一轮工作。
|
||
preceding_notices = rec.directives.preceding_user_notices or []
|
||
if isinstance(preceding_notices, list):
|
||
for item in preceding_notices:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
notice_msg = str(item.get("message") or "").strip()
|
||
if not notice_msg:
|
||
continue
|
||
notice_payload = dict(item.get("payload") or {})
|
||
notice_payload["starts_work"] = False
|
||
notice_event = {
|
||
"message": notice_msg,
|
||
"conversation_id": rec.conversation_id,
|
||
"task_id": rec.task_id,
|
||
}
|
||
notice_event.update(notice_payload)
|
||
self._append_event(rec, "user_message", notice_event)
|
||
extra_payload = rec.directives.auto_user_message_payload or {}
|
||
if not isinstance(extra_payload, dict):
|
||
extra_payload = {}
|
||
payload = {
|
||
"message": rec.message,
|
||
"conversation_id": rec.conversation_id,
|
||
"task_id": rec.task_id,
|
||
}
|
||
payload.update(extra_payload)
|
||
self._append_event(rec, "user_message", payload)
|
||
except Exception as exc:
|
||
debug_log(f"[Task] 注入 user_message 事件失败: {exc}")
|
||
|
||
def sender(event_type, data):
|
||
if isinstance(data, dict):
|
||
data = dict(data)
|
||
if event_type == "compression_finished":
|
||
migrated_conversation_id = str(data.get("conversation_id") or "").strip()
|
||
if migrated_conversation_id:
|
||
with self._lock:
|
||
rec.conversation_id = migrated_conversation_id
|
||
rec.updated_at = time.time()
|
||
data.setdefault("task_id", rec.task_id)
|
||
if rec.conversation_id:
|
||
data.setdefault("conversation_id", rec.conversation_id)
|
||
if rec.workspace_id:
|
||
data.setdefault("workspace_id", rec.workspace_id)
|
||
# 记录事件到任务事件流(轮询通道,唯一出口)
|
||
self._append_event(rec, event_type, data)
|
||
|
||
# 轮询模式需要把 context_manager 的回调切到当前任务 sender,
|
||
# 否则 token_update 等事件只走 websocket,前端任务轮询拿不到实时更新。
|
||
previous_ctx_callback = None
|
||
try:
|
||
if terminal and getattr(terminal, "context_manager", None):
|
||
previous_ctx_callback = getattr(terminal.context_manager, "_web_terminal_callback", None)
|
||
terminal.context_manager.set_web_terminal_callback(sender)
|
||
except Exception as exc:
|
||
debug_log(f"[Task] 设置上下文回调失败: {exc}")
|
||
|
||
# 将 task_id 作为 client_sid,供 stop_flags 检测
|
||
previous_auto_user_event = None
|
||
previous_message_source = None
|
||
previous_auto_user_payload = None
|
||
previous_goal_mode_requested = None
|
||
previous_skill_context_messages = None
|
||
try:
|
||
previous_auto_user_event = getattr(terminal, "_auto_user_message_event", False)
|
||
previous_message_source = getattr(terminal, "_current_user_message_source", None)
|
||
previous_auto_user_payload = getattr(terminal, "_auto_user_message_payload", None)
|
||
previous_goal_mode_requested = getattr(terminal, "_goal_mode_requested", False)
|
||
previous_skill_context_messages = getattr(terminal, "_skill_context_messages", None)
|
||
setattr(
|
||
terminal,
|
||
"_auto_user_message_event",
|
||
bool(rec.directives.auto_user_message_event),
|
||
)
|
||
setattr(
|
||
terminal,
|
||
"_auto_user_message_payload",
|
||
dict(rec.directives.auto_user_message_payload or {}),
|
||
)
|
||
setattr(
|
||
terminal,
|
||
"_current_user_message_source",
|
||
str(rec.task_params.message_source or "user"),
|
||
)
|
||
setattr(
|
||
terminal,
|
||
"_goal_mode_requested",
|
||
bool(rec.task_params.goal_mode),
|
||
)
|
||
setattr(
|
||
terminal,
|
||
"_skill_context_messages",
|
||
list(rec.task_params.skill_context_messages or []),
|
||
)
|
||
# 审批/提问等待超时透传(契约 §6):None = 保持既有默认语义(3600s),
|
||
# 超时后的语义属阶段三产品决策,本阶段仅建立透传机制。
|
||
setattr(
|
||
terminal,
|
||
"_approval_timeout_seconds",
|
||
rec.task_params.approval_timeout_seconds,
|
||
)
|
||
except Exception:
|
||
previous_auto_user_event = None
|
||
previous_message_source = None
|
||
previous_auto_user_payload = None
|
||
previous_goal_mode_requested = None
|
||
previous_skill_context_messages = None
|
||
|
||
try:
|
||
# 延迟导入:消除任务核心层对 Web 路由层(server/chat_flow.py)的静态依赖,
|
||
# 使本模块可在无 Web 应用初始化的进程中加载(Gateway 独立启动前提)。
|
||
from server.chat_flow import run_chat_task_sync
|
||
|
||
run_chat_task_sync(
|
||
terminal=terminal,
|
||
message=rec.message,
|
||
images=images,
|
||
sender=sender,
|
||
client_sid=rec.task_id,
|
||
workspace=workspace,
|
||
username=username,
|
||
videos=videos,
|
||
files=files or [],
|
||
# 通知链任务认领轮询器预占的门闸(其余任务为 None,走竞争获取)
|
||
main_task_gate_token=rec.directives.main_task_gate_token,
|
||
)
|
||
finally:
|
||
try:
|
||
if previous_auto_user_event is not None:
|
||
setattr(terminal, "_auto_user_message_event", previous_auto_user_event)
|
||
if previous_auto_user_payload is not None:
|
||
setattr(terminal, "_auto_user_message_payload", previous_auto_user_payload)
|
||
if previous_message_source is not None:
|
||
setattr(terminal, "_current_user_message_source", previous_message_source)
|
||
if previous_goal_mode_requested is not None:
|
||
setattr(terminal, "_goal_mode_requested", previous_goal_mode_requested)
|
||
if previous_skill_context_messages is not None:
|
||
setattr(terminal, "_skill_context_messages", previous_skill_context_messages)
|
||
else:
|
||
setattr(terminal, "_skill_context_messages", [])
|
||
setattr(terminal, "_approval_timeout_seconds", None)
|
||
if terminal and getattr(terminal, "context_manager", None):
|
||
terminal.context_manager.set_web_terminal_callback(previous_ctx_callback)
|
||
except Exception as exc:
|
||
debug_log(f"[Task] 恢复上下文回调失败: {exc}")
|
||
|
||
# 结束状态
|
||
canceled_flag = rec.stop_requested or stop_hint or bool(stop_flags.get(rec.task_id, {}).get("stop"))
|
||
if canceled_flag:
|
||
# 用户取消:主任务一律以 stopped 终态收尾。
|
||
# cancel_requested 仅作为 cancel_task 到本收尾之间的瞬态(此期间仍视为
|
||
# 活跃,防止前端对账在收尾间隙误清运行态/重复重放)。
|
||
# 后台任务(子智能体/后台命令)有独立的停止入口与运行状态,主任务不再
|
||
# 为其保持 cancel_requested 等待「第二下点击」——该交互已废弃,保持
|
||
# cancel_requested 只会让任务永久卡在活跃集合,被 bootstrap / 对账当作
|
||
# 「最新活跃主任务」反复重放死任务事件流(显示回退事故的根因)。
|
||
bg_state = self._has_running_background(rec, terminal)
|
||
has_bg = bg_state["has_running_sub_agents"] or bg_state["has_running_background_commands"]
|
||
with self._lock:
|
||
new_status = "stopped"
|
||
rec.status = new_status
|
||
rec.updated_at = time.time()
|
||
debug_log(
|
||
f"[TaskRun] 任务线程结束: task_id={rec.task_id}, canceled_flag={canceled_flag}, "
|
||
f"new_status={new_status}, bg_state={bg_state}"
|
||
)
|
||
# 统一发送 task_stopped,携带后台任务状态
|
||
try:
|
||
stopped_payload = {
|
||
'message': tr("task_main.task_stopped"),
|
||
'reason': 'user_requested',
|
||
'task_id': rec.task_id,
|
||
'conversation_id': rec.conversation_id,
|
||
'has_running_sub_agents': bg_state["has_running_sub_agents"],
|
||
'has_running_background_commands': bg_state["has_running_background_commands"],
|
||
}
|
||
# 事件流(轮询通道)为唯一权威出口,不再做 WebSocket 实时推送。
|
||
self._append_event(rec, "task_stopped", stopped_payload)
|
||
debug_log(
|
||
f"[TaskRun] 已发送 task_stopped: task_id={rec.task_id}, "
|
||
f"has_bg={has_bg}, room=user_{rec.username}"
|
||
)
|
||
except Exception as exc:
|
||
debug_log(f"[TaskRun] 发送 task_stopped 失败: {exc}")
|
||
else:
|
||
with self._lock:
|
||
rec.status = "succeeded"
|
||
rec.updated_at = time.time()
|
||
|
||
# 任务线程结束:仅当对话真正空闲(无其它前台/后台任务)时才把 work_timer 标记为完成,
|
||
# 避免智能体已停、但后台子智能体/后台命令/压缩仍在进行时提前停止计时。
|
||
try:
|
||
if terminal and rec.conversation_id:
|
||
active_task_ids = {
|
||
t.task_id
|
||
for t in self.list_tasks(username, rec.workspace_id)
|
||
if t.status in {"pending", "running"}
|
||
}
|
||
finalized = finalize_conversation_work_timer(
|
||
terminal,
|
||
rec.conversation_id,
|
||
exclude_task_id=rec.task_id,
|
||
active_task_ids=active_task_ids,
|
||
)
|
||
except Exception as exc:
|
||
pass
|
||
except Exception as exc:
|
||
debug_log(f"[Task] 后台任务失败: {exc}")
|
||
self._append_event(rec, "error", {"message": str(exc)})
|
||
with self._lock:
|
||
rec.status = "failed"
|
||
rec.error = str(exc)
|
||
rec.updated_at = time.time()
|
||
finally:
|
||
# 清理 stop_flags
|
||
stop_flags.pop(rec.task_id, None)
|
||
# 主任务门闸兜底释放:若任务线程在 process_message_task 认领前异常退出,
|
||
# 按受理时移交的 token 释放,避免门闸泄漏导致对话永久被占用。
|
||
# 正常路径下 process_message_task 已在 finally 释放,此处为无操作。
|
||
try:
|
||
gate_token = rec.directives.main_task_gate_token
|
||
if gate_token and terminal:
|
||
release_main_task_gate(terminal, gate_token)
|
||
except Exception:
|
||
pass
|
||
# 清理一次性配置
|
||
if terminal and hasattr(terminal, "max_iterations_override"):
|
||
try:
|
||
delattr(terminal, "max_iterations_override")
|
||
except Exception:
|
||
terminal.max_iterations_override = None
|
||
|
||
def start_task_cleanup_scheduler():
|
||
"""启动任务清理定时器"""
|
||
def cleanup_loop():
|
||
while True:
|
||
try:
|
||
from server.tasks import task_manager
|
||
count = task_manager.cleanup_old_tasks(3600)
|
||
if count > 0:
|
||
debug_log(f"[Task] 清理了 {count} 个旧任务")
|
||
except Exception as e:
|
||
debug_log(f"[Task] 清理任务失败: {e}")
|
||
time.sleep(600) # 每 10 分钟
|
||
|
||
thread = threading.Thread(target=cleanup_loop, daemon=True, name="TaskCleanup")
|
||
thread.start()
|
||
debug_log("[Task] 任务清理定时器已启动")
|
||
|
||
|
||
# ---- 任务公开载荷序列化(核心层唯一实现;Web 路由与公共入口共用,保证多端同构)----
|
||
|
||
|
||
def conversation_title_for_task(rec: TaskRecord) -> Optional[str]:
|
||
conv_id = (getattr(rec, "conversation_id", None) or "").strip()
|
||
if not conv_id:
|
||
return None
|
||
if not conv_id.startswith("conv_"):
|
||
conv_id = f"conv_{conv_id}"
|
||
try:
|
||
if rec.username == "host":
|
||
path = Path(DATA_DIR).expanduser().resolve() / "conversations" / rec.workspace_id / f"{conv_id}.json"
|
||
else:
|
||
import server.state as state
|
||
|
||
workspace = state.user_manager.ensure_user_workspace(rec.username, rec.workspace_id or "default")
|
||
path = Path(workspace.data_dir).expanduser().resolve() / "conversations" / f"{conv_id}.json"
|
||
if not path.exists():
|
||
return None
|
||
data = json.loads(path.read_text(encoding="utf-8"))
|
||
title = str(data.get("title") or "").strip()
|
||
return title or None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def workspace_label_for_task(rec: TaskRecord) -> Optional[str]:
|
||
try:
|
||
if rec.username == "host":
|
||
from modules.host_workspace_manager import load_host_workspace_catalog
|
||
|
||
catalog = load_host_workspace_catalog()
|
||
for item in catalog.get("workspaces") or []:
|
||
if item.get("workspace_id") == rec.workspace_id:
|
||
return str(item.get("label") or rec.workspace_id)
|
||
else:
|
||
import server.state as state
|
||
|
||
item = state.user_manager.list_user_workspaces(rec.username).get(rec.workspace_id)
|
||
if item:
|
||
return str(item.get("label") or rec.workspace_id)
|
||
except Exception:
|
||
pass
|
||
return rec.workspace_id
|
||
|
||
|
||
def task_public_payload(rec: TaskRecord, *, include_title: bool = True) -> Dict[str, Any]:
|
||
payload = {
|
||
"task_id": rec.task_id,
|
||
"username": rec.username,
|
||
"workspace_id": rec.workspace_id,
|
||
"workspace_label": workspace_label_for_task(rec),
|
||
"status": rec.status,
|
||
"created_at": rec.created_at,
|
||
"updated_at": rec.updated_at,
|
||
"message": rec.message,
|
||
"conversation_id": rec.conversation_id,
|
||
"error": rec.error,
|
||
"message_source": rec.task_params.message_source,
|
||
"goal_mode": bool(rec.task_params.goal_mode),
|
||
"goal_progress": rec.goal_progress,
|
||
"task_type": getattr(rec, "task_type", "chat"),
|
||
}
|
||
if include_title:
|
||
payload["conversation_title"] = conversation_title_for_task(rec)
|
||
return payload
|