refactor(stop-button): 拆分停止按钮和后台任务控制

停止按钮现在只停主智能体,与后台任务无关。
- 后端 cancel_task 简化:移除两段式判断、force_stop参数、is_ma_mode分支
- 删除 _force_stop_multi_agent 方法
- 新增 /api/sub_agents/stop_all 接口(mode=terminate/soft_stop)
- 前端 stopTask 移除 forceStop 概念,只取消主智能体
- 取消期间不再二次点击终结子智能体

子智能体停止按钮:
- InputComposer state bar 加'x个子智能体运行中'按钮
- 独立浮层显示,不依赖 git 栏(参考 askuser 方案)
- 点击传统模式弹窗终结;多智能体弹窗软停止
- App.vue 新增 handleStopAllSubAgents 调 ui.confirmDialog

后台指令停止按钮:
- LeftPanel 后台指令标签右边加停止按钮
- 仅当 panelMode=backgroundCommands 且存在运行中后台指令时显示
- 调 backgroundCommandStore.stopAllCommands 无二次确认

showStopIcon 只看主对话 streaming 状态:
- 后台任务在跑但主对话空闲时显示发送按钮
- 多智能体 / 传统模式统一处理

子智能体 store 加 stopAllAgents(mode) action 调新 API

useLegacySocket task_stopped handler 简化:
- 移除 multi_agent_reloaded 重载
- 移除'再次按下停止按钮以结束后台任务'提示
- 保留 has_running_multi_agent/sub_agents 用于 taskInProgress 判定

清理调试日志:[TaskCancel][stop_debug], [STOP_DEBUG] 全部去掉
保留 ma_debug 框架用于多智能体调试
This commit is contained in:
JOJO 2026-07-14 18:34:56 +08:00
parent 30bfb79cbd
commit ae70e4902e
15 changed files with 293 additions and 392 deletions

View File

@ -393,7 +393,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
让子智能体在当前工具完成后进入 idle 状态保留上下文 让子智能体在当前工具完成后进入 idle 状态保留上下文
返回实际发出软停止信号的子智能体数量 返回实际发出软停止信号的子智能体数量
""" """
debug_log(f"[TaskCancel][stop_debug] soft_stop_all_agents 进入conv={conversation_id}") ma_debug("soft_stop_all_agents_enter", conversation_id=conversation_id)
count = 0 count = 0
matched = 0 matched = 0
skipped_terminal = 0 skipped_terminal = 0
@ -403,27 +403,35 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
continue continue
matched += 1 matched += 1
status = task_info.get("status") status = task_info.get("status")
debug_log(f"[TaskCancel][stop_debug] soft_stop 遍历: task_id={task_id}, status={status}") ma_debug(
"soft_stop_iter",
task_id=task_id,
status=status,
)
if status in TERMINAL_STATUSES.union({"terminated", "idle"}): if status in TERMINAL_STATUSES.union({"terminated", "idle"}):
skipped_terminal += 1 skipped_terminal += 1
debug_log(f"[TaskCancel][stop_debug] soft_stop 跳过 (terminal/idle): task_id={task_id}, status={status}")
continue continue
agent_id = task_info.get("agent_id") agent_id = task_info.get("agent_id")
if agent_id is None: if agent_id is None:
continue continue
# 從 _sub_agent_instances 查找 SubAgentTask 实例 # 從 _sub_agent_instances 查找 SubAgentTask 实例
sub_agent_task = self._sub_agent_instances.get(agent_id) sub_agent_task = self._sub_agent_instances.get(agent_id)
debug_log(f"[TaskCancel][stop_debug] soft_stop _sub_agent_instances 查找: agent_id={agent_id}, found={bool(sub_agent_task)}")
if sub_agent_task and hasattr(sub_agent_task, "request_soft_stop"): if sub_agent_task and hasattr(sub_agent_task, "request_soft_stop"):
try: try:
sub_agent_task.request_soft_stop() sub_agent_task.request_soft_stop()
count += 1 count += 1
debug_log(f"[TaskCancel][stop_debug] soft_stop 已请求: task_id={task_id}, agent_id={agent_id}")
except Exception as exc: except Exception as exc:
debug_log(f"[TaskCancel][stop_debug] soft_stop 请求失败: task_id={task_id}, error={exc}") ma_debug("soft_stop_failed", task_id=task_id, error=str(exc))
else: else:
skipped_no_instance += 1 skipped_no_instance += 1
debug_log(f"[TaskCancel][stop_debug] soft_stop_all_agents 结束: matched={matched}, count={count}, skipped_terminal={skipped_terminal}, skipped_no_instance={skipped_no_instance}") ma_debug(
"soft_stop_all_agents_done",
conversation_id=conversation_id,
matched=matched,
count=count,
skipped_terminal=skipped_terminal,
skipped_no_instance=skipped_no_instance,
)
return count return count
def terminate_sub_agent( def terminate_sub_agent(

View File

@ -1069,32 +1069,32 @@ class SubAgentTask:
- request_soft_stop(): 设标志 + cancel asyncio.Task CancelledError - request_soft_stop(): 设标志 + cancel asyncio.Task CancelledError
捕获后重载对话文件丢弃半成品 mark idle 保留实例 捕获后重载对话文件丢弃半成品 mark idle 保留实例
""" """
from server.utils_common import debug_log as _dbg
_dbg(f"[TaskCancel][stop_debug] request_soft_stop 进入: task_id={self.task_id}, agent_id={self.agent_id}, _soft_stop 前={self._soft_stop}, _task={self._task}, _task.done()={self._task.done() if self._task else 'n/a'}")
self._soft_stop = True self._soft_stop = True
ma_debug(
"sub_agent_request_soft_stop",
task_id=self.task_id,
agent_id=self.agent_id,
has_task=bool(self._task),
task_done=self._task.done() if self._task else None,
)
# 取消子智能体正在阻塞等待的 ask_master/ask_other 未来,解除阻塞 # 取消子智能体正在阻塞等待的 ask_master/ask_other 未来,解除阻塞
if self.multi_agent_state and self.agent_id is not None: if self.multi_agent_state and self.agent_id is not None:
try: try:
self.multi_agent_state.cancel_pending_question_for_agent(self.agent_id) self.multi_agent_state.cancel_pending_question_for_agent(self.agent_id)
_dbg(f"[TaskCancel][stop_debug] request_soft_stop cancel_pending_question_for_agent 已调 agent_id={self.agent_id}") except Exception:
except Exception as exc: pass
_dbg(f"[TaskCancel][stop_debug] request_soft_stop cancel_pending_question_for_agent 失败: {exc}")
# 直接 cancel asyncio.Task 以中断正在执行的工具调用/API 调用 # 直接 cancel asyncio.Task 以中断正在执行的工具调用/API 调用
# CancelledError 会在 run() 中被捕获,检测到 _soft_stop 后走恢复路径 # CancelledError 会在 run() 中被捕获,检测到 _soft_stop 后走恢复路径
if self._task and not self._task.done(): if self._task and not self._task.done():
try: try:
loop = self._task.get_loop() loop = self._task.get_loop()
loop.call_soon_threadsafe(self._task.cancel) loop.call_soon_threadsafe(self._task.cancel)
_dbg(f"[TaskCancel][stop_debug] request_soft_stop call_soon_threadsafe(cancel) 已投递 task_id={self.task_id}") except Exception:
except Exception as exc:
_dbg(f"[TaskCancel][stop_debug] request_soft_stop call_soon_threadsafe 失败: {exc}")
self._task.cancel() self._task.cancel()
else:
_dbg(f"[TaskCancel][stop_debug] request_soft_stop 无可用 _task无法 cancel (done or None): task_id={self.task_id}")
# 额外保险:如果 _task 为 None 但子智能体状态是 running直接设 idle 状态 # 额外保险:如果 _task 为 None 但子智能体状态是 running直接设 idle 状态
if self._task is None and self.multi_agent_state and self.agent_id is not None: if self._task is None and self.multi_agent_state and self.agent_id is not None:
_dbg(f"[TaskCancel][stop_debug] request_soft_stop _task 为 None直接 mark idle: task_id={self.task_id}") ma_debug("sub_agent_soft_stop_no_task_mark_idle", task_id=self.task_id, agent_id=self.agent_id)
self._soft_stop = False self._soft_stop = False
self._idle = True self._idle = True
self.multi_agent_state.mark_status(self.agent_id, "idle") self.multi_agent_state.mark_status(self.agent_id, "idle")

View File

@ -1808,6 +1808,55 @@ def get_sub_agent_activity(task_id: str, terminal: WebTerminal, workspace: UserW
return jsonify({"success": False, "error": str(exc)}), 500 return jsonify({"success": False, "error": str(exc)}), 500
@conversation_bp.route('/api/sub_agents/stop_all', methods=['POST'])
@api_login_required
@with_terminal
def stop_all_sub_agents(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""批量停止当前会话的所有非终态子智能体。
body: {"mode": "terminate" | "soft_stop"}
- mode=terminate: 传统模式终结子智能体不保留实例
- mode=soft_stop: 多智能体模式软停止保留实例可被后续唤醒
"""
data = request.get_json(silent=True) or {}
mode = str(data.get('mode') or 'terminate').strip().lower()
if mode not in {'terminate', 'soft_stop'}:
return jsonify({"success": False, "error": "mode 只支持 terminate/soft_stop"}), 400
manager = getattr(terminal, 'sub_agent_manager', None)
if not manager:
return jsonify({"success": False, "error": "子智能体管理器不可用"}), 404
conversation_id = terminal.context_manager.current_conversation_id
try:
try:
manager._load_state()
except Exception:
pass
manager.reconcile_task_states(conversation_id=conversation_id)
stopped_count = 0
if mode == 'soft_stop':
stopped_count = manager.soft_stop_all_agents(conversation_id)
debug_log(f"[StopAllSubAgents] soft_stop 会话={conversation_id}, 计数={stopped_count}")
else:
# terminate: 遍历所有非终态子智能体逐个终结
from modules.sub_agent.state import TERMINAL_STATUSES
for task_info in list(manager.tasks.values()):
if task_info.get('conversation_id') != conversation_id:
continue
status = task_info.get('status')
if status in TERMINAL_STATUSES.union({"terminated"}):
continue
try:
tid = task_info.get('task_id')
manager.terminate_sub_agent(task_id=tid)
stopped_count += 1
debug_log(f"[StopAllSubAgents] terminated task_id={tid}")
except Exception as exc:
debug_log(f"[StopAllSubAgents] 终止失败 tid={task_info.get('task_id')}, err={exc}")
return jsonify({"success": True, "data": {"stopped_count": stopped_count, "mode": mode}})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
@conversation_bp.route('/api/sub_agents/<task_id>/terminate', methods=['POST']) @conversation_bp.route('/api/sub_agents/<task_id>/terminate', methods=['POST'])
@api_login_required @api_login_required
@with_terminal @with_terminal

View File

@ -205,9 +205,7 @@ def cancel_task_api(task_id: str):
rec = task_manager.get_task(username, task_id) rec = task_manager.get_task(username, task_id)
if not rec: if not rec:
return jsonify({"success": False, "error": "任务不存在"}), 404 return jsonify({"success": False, "error": "任务不存在"}), 404
force_stop = bool(request.json.get("force_stop", False)) if request.is_json else False ok = task_manager.cancel_task(username, task_id)
debug_log(f"[TaskCancel][stop_debug] cancel_task_api 收到请求: task_id={task_id}, force_stop={force_stop}, body={request.json if request.is_json else None}")
ok = task_manager.cancel_task(username, task_id, force_stop=force_stop)
# 用户取消任务时,一并停止该工作区的目标模式,避免后续新对话继承旧目标。 # 用户取消任务时,一并停止该工作区的目标模式,避免后续新对话继承旧目标。
try: try:
if ok and rec.workspace_id: if ok and rec.workspace_id:

View File

@ -211,19 +211,21 @@ class TaskManager:
if rec.username == username and (workspace_id is None or rec.workspace_id == workspace_id) if rec.username == username and (workspace_id is None or rec.workspace_id == workspace_id)
] ]
def cancel_task(self, username: str, task_id: str, force_stop: bool = False) -> bool: def cancel_task(self, username: str, task_id: str) -> bool:
debug_log(f"[TaskCancel][stop_debug] === cancel_task 被调 === task_id={task_id}, force_stop={force_stop}") """取消主智能体任务。只停主智能体,不触碰后台任务。
后台任务子智能体后台指令的终止/暂停由独立的 API 处理
参见 /api/sub_agents/stop_all /api/background_commands/stop_all
"""
rec = self.get_task(username, task_id) rec = self.get_task(username, task_id)
if not rec: if not rec:
debug_log(f"[TaskCancel][stop_debug] cancel_task 找不到任务: task_id={task_id}") debug_log(f"[TaskCancel] cancel_task 找不到任务: task_id={task_id}")
return False return False
# 立即快照进入时的状态,后续所有两段式判断都用它。 task_id = rec.task_id
# _run_chat_task finally 会在硬取消后并发修改 rec.status
# 如果后面再读会被误判为第二下。
status_at_entry = rec.status status_at_entry = rec.status
# 先取终端引用,后续多智能体模式与两段式都要用 # 取终端引用(仅用于停止目标模式等副作用)
entry = stop_flags.get(task_id) entry = stop_flags.get(task_id)
terminal = None terminal = None
if isinstance(entry, dict): if isinstance(entry, dict):
@ -231,48 +233,32 @@ class TaskManager:
if not terminal and rec.workspace_id: if not terminal and rec.workspace_id:
try: try:
terminal, _ = get_user_resources(username, rec.workspace_id) terminal, _ = get_user_resources(username, rec.workspace_id)
debug_log(f"[TaskCancel][stop_debug] fallback get_user_resources 获取 terminal OK") except Exception:
except Exception as exc: pass
debug_log(f"[TaskCancel][stop_debug] fallback get_user_resources 失败: {exc}")
is_ma_mode = bool(getattr(terminal, 'multi_agent_mode', False)) if terminal else False
debug_log( debug_log(
f"[TaskCancel][stop_debug] 入口状态: task_id={task_id}, status={status_at_entry}, " f"[TaskCancel] 入口: task_id={task_id}, status={status_at_entry}, "
f"conversation_id={rec.conversation_id}, workspace_id={rec.workspace_id}, " f"conv={rec.conversation_id}"
f"force_stop={force_stop}, multi_agent_mode={is_ma_mode}, terminal_present={bool(terminal)}"
) )
# 多智能体模式 force_stop跳过两段式直接走强制停止路径
# 适用场景:第一下已停主智能体,第二下停所有子智能体并重载对话
if force_stop:
debug_log(f"[TaskCancel][stop_debug] force_stop=true → 走 _force_stop_multi_agent 分支")
return self._force_stop_multi_agent(rec, entry, terminal, username)
debug_log(f"[TaskCancel][stop_debug] force_stop=false → 走两段式分支(可诱导出终端)")
# 1. 硬取消主 asyncio task如果引用还在 # 1. 硬取消主 asyncio task如果引用还在
if isinstance(entry, dict): if isinstance(entry, dict):
loop = entry.get('loop') loop = entry.get('loop')
task = entry.get('task') task = entry.get('task')
debug_log(
f"[TaskCancel][stop_debug] stop_flags entry: loop_exists={loop is not None}, "
f"task_exists={task is not None}, task_done={task.done() if task else 'n/a'}"
)
if loop and task and not task.done(): if loop and task and not task.done():
try: try:
loop.call_soon_threadsafe(task.cancel) loop.call_soon_threadsafe(task.cancel)
debug_log(f"[TaskCancel][stop_debug] 已投递硬取消: task_id={task_id}") debug_log(f"[TaskCancel] 已投递硬取消: task_id={task_id}")
except Exception as exc: except Exception as exc:
debug_log(f"[TaskCancel][stop_debug] 硬取消失败: task_id={task_id}, error={exc}") debug_log(f"[TaskCancel] 硬取消失败: task_id={task_id}, error={exc}")
entry['stop'] = True entry['stop'] = True
else: else:
debug_log(f"[TaskCancel][stop_debug] stop_flags 无 entry设软标志: task_id={task_id}")
stop_flags[task_id] = {'stop': True, 'task': None, 'terminal': None, 'loop': None} stop_flags[task_id] = {'stop': True, 'task': None, 'terminal': None, 'loop': None}
entry = stop_flags[task_id] entry = stop_flags[task_id]
rec.stop_requested = True rec.stop_requested = True
# 2. 停止目标模式 # 2. 停止目标模式
workspace = None
if rec.workspace_id: if rec.workspace_id:
try: try:
_, workspace = get_user_resources(username, rec.workspace_id) _, workspace = get_user_resources(username, rec.workspace_id)
@ -289,204 +275,20 @@ class TaskManager:
rec.runtime_guidance_queue = [] rec.runtime_guidance_queue = []
rec.updated_at = time.time() rec.updated_at = time.time()
# 4. 两段式停止:严格按进入时的 rec.status 判断(快照),不依赖 task.done() 的异步时机。 # 4. 标记为 cancel_requested。
# 原因:投递硬取消后 _run_chat_task finally 可能并发把 status 改成 cancel_requested # _run_chat_task finally 会并发把 status 改为 stopped 并发 task_stopped 事件。
# 若此时再读 rec.status 会误判为第二下,导致第一下就误杀后台。 # 此处仅做软标记,不强制覆盖 finally 即将设的终态。
# - 第一下running/pending只硬取消主智能体并置为 cancel_requested保留后台任务。
# - 第二下cancel_requested/stopped/canceled清理后台任务并置为 stopped。
# - 额外保护:极短时间内(<500ms的重复 cancel 请求视为抖动,不执行第二下。
now = time.time() now = time.time()
is_first_press = status_at_entry in {"running", "pending"}
if not terminal and rec.workspace_id:
try:
terminal, _ = get_user_resources(username, rec.workspace_id)
except Exception as exc:
debug_log(f"[TaskCancel] 取消任务时重新获取 terminal 失败: {exc}")
if is_first_press:
# 第一下:主智能体仍在跑,只请求取消,保留后台任务。
# 注意 1不要清空 rec.events否则 _run_chat_task finally 发送的
# task_stopped 事件会被丢弃,前端无法感知并弹提示。
# 注意 2_run_chat_task finally 可能并发先执行并把 status 设为 stopped
# 此时不要再覆盖回 cancel_requested。
debug_log(f"[TaskCancel][stop_debug] 第一下分支ma_mode={is_ma_mode}, 仅取消主智能体")
with self._lock: with self._lock:
if rec.status in {"running", "pending"}: if rec.status in {"running", "pending"}:
rec.status = "cancel_requested" rec.status = "cancel_requested"
rec.updated_at = now rec.updated_at = now
rec.last_cancel_at = now rec.last_cancel_at = now
debug_log( debug_log(
f"[TaskCancel] 第一下:请求取消智能体,保留后台任务: " f"[TaskCancel] 已取消主智能体: task_id={task_id}, "
f"task_id={task_id}, status_at_entry={status_at_entry}, " f"status_at_entry={status_at_entry}, current_status={rec.status}"
f"current_status={rec.status}"
) )
return True return True
else:
debug_log(f"[TaskCancel][stop_debug] 第二下分支进入status_at_entry={status_at_entry}, is_ma_mode={is_ma_mode}")
# 第二下:清理后台任务(过滤 500ms 内的重复请求)
if rec.last_cancel_at is not None and (now - rec.last_cancel_at) < 0.5:
debug_log(
f"[TaskCancel][stop_debug] 500ms 内重复取消,忽略: task_id={task_id}, "
f"elapsed={now - rec.last_cancel_at:.3f}s"
)
return True
is_ma_mode = bool(getattr(terminal, "multi_agent_mode", False))
debug_log(f"[TaskCancel][stop_debug] 准备清理后台任务is_ma_mode={is_ma_mode}")
if is_ma_mode:
# 多智能体模式:第二下不应走 terminate 路径!
# 应改为软停止所有子智能体,与 force_stop 一致。
debug_log(f"[TaskCancel][stop_debug] 多智能体模式第二下分支,转走软停止路径(不是 terminate")
soft_stop_count = 0
if terminal:
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)
soft_stop_count = sub_agent_manager.soft_stop_all_agents(rec.conversation_id)
debug_log(f"[TaskCancel][stop_debug] 软停止 {soft_stop_count} 个子智能体已发出")
except Exception as exc:
debug_log(f"[TaskCancel][stop_debug] soft_stop_all_agents 失败: {exc}")
# 清空主智能体多智能体运行态(与旧逻辑一致)
if terminal and rec.conversation_id:
try:
ma_state = terminal.sub_agent_manager.get_multi_agent_state(rec.conversation_id)
if ma_state:
ma_state.clear()
debug_log(f"[TaskCancel][stop_debug] 多智能体状态已清空: conv={rec.conversation_id}")
except Exception as exc:
debug_log(f"[TaskCancel][stop_debug] 清空多智能体状态失败: {exc}")
has_running_background = False
else:
# 传统模式:第二下继续走 terminate 路径
debug_log(f"[TaskCancel][stop_debug] 传统模式第二下分支,继续 _cleanup_background_tasks terminate")
has_running_background = self._cleanup_background_tasks(rec, terminal)
with self._lock:
rec.status = "stopped"
rec.updated_at = time.time()
rec.last_cancel_at = time.time()
rec.events.clear()
debug_log(
f"[TaskCancel] 第二下:清理后台并停止: task_id={task_id}, "
f"has_running_background={has_running_background}"
)
# 多智能体模式:从已保存对话文件恢复,丢弃运行中的半成品状态
# (调用一半的工具、输出一半的内容等不会保存在对话文件里)
reload_ok = False
messages_count = 0
if is_ma_mode and terminal and rec.conversation_id:
try:
result = terminal.load_conversation(rec.conversation_id, restore_model=False)
if result.get("success"):
reload_ok = True
messages_count = result.get("messages_count", 0)
debug_log(f"[TaskCancel] 多智能体对话已从文件恢复: conv={rec.conversation_id}, messages={messages_count}")
else:
debug_log(f"[TaskCancel] 多智能体对话恢复失败: {result.get('message', '')}")
except Exception as exc:
debug_log(f"[TaskCancel] 多智能体对话恢复异常: {exc}")
try:
from server.extensions import socketio
socketio.emit('task_stopped', {
'message': '任务已停止',
'reason': 'user_requested',
'task_id': task_id,
'conversation_id': rec.conversation_id,
'has_running_sub_agents': False,
'has_running_background_commands': False,
'has_running_multi_agent': False,
'multi_agent_reloaded': reload_ok,
'messages_count': messages_count,
}, room=f"user_{username}")
except Exception as exc:
debug_log(f"[TaskCancel] 发送 task_stopped 事件失败: {exc}")
return True
def _force_stop_multi_agent(self, rec: TaskRecord, entry, terminal, username: str) -> bool:
"""多智能体模式强制停止:软停止所有子智能体 → 重载主对话 → 变空闲。
与传统模式第二下的区别
- 传统模式第二下terminate 后台子智能体会终止进程并留下终止记录
- 多智能体模式软停止子智能体完成当前工具后变 idle
+ 从已保存对话文件恢复主对话丢弃主智能体接收到的半成品输出
"""
debug_log(f"[TaskCancel][stop_debug] === _force_stop_multi_agent 被调 === task_id={rec.task_id}")
task_id = rec.task_id
# 1. 先确保主智能体也取消(如果还在跑)
if entry:
entry['stop'] = True
debug_log(f"[TaskCancel][stop_debug] _force_stop_multi_agent entry.stop=True")
rec.stop_requested = True
# 2. 获取 terminal
if not terminal and rec.workspace_id:
try:
terminal, _ = get_user_resources(username, rec.workspace_id)
debug_log(f"[TaskCancel][stop_debug] _force_stop_multi_agent fallback get terminal OK")
except Exception as exc:
debug_log(f"[TaskCancel][stop_debug] _force_stop_multi_agent fallback get terminal 失败: {exc}")
# 3. 软停止所有子智能体(不 terminate让它们在当前工具完成后变 idle
soft_stop_count = 0
if terminal:
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)
soft_stop_count = sub_agent_manager.soft_stop_all_agents(rec.conversation_id)
debug_log(f"[TaskCancel][stop_debug] force_stop 软停止 {soft_stop_count} 个子智能体")
except Exception as exc:
debug_log(f"[TaskCancel][stop_debug] force_stop 软停止子智能体异常: {exc}")
else:
debug_log(f"[TaskCancel][stop_debug] terminal 无 sub_agent_manager无法软停止")
else:
debug_log(f"[TaskCancel][stop_debug] terminal 为 None无法软停止")
# 4. 标记任务已停止
with self._lock:
rec.status = "stopped"
rec.updated_at = time.time()
rec.events.clear()
# 5. 从已保存对话文件恢复,丢弃主对话半成品状态
reload_ok = False
messages_count = 0
if terminal and rec.conversation_id:
try:
result = terminal.load_conversation(rec.conversation_id, restore_model=False)
if result.get("success"):
reload_ok = True
messages_count = result.get("messages_count", 0)
debug_log(f"[TaskCancel] force_stop 主对话已从文件恢复: conv={rec.conversation_id}, messages={messages_count}")
else:
debug_log(f"[TaskCancel] force_stop 对话恢复失败: {result.get('message', '')}")
except Exception as exc:
debug_log(f"[TaskCancel] force_stop 对话恢复异常: {exc}")
# 6. 发送 task_stopped 事件通知前端
try:
from server.extensions import socketio
socketio.emit('task_stopped', {
'message': '所有子智能体已停止',
'reason': 'force_stop',
'task_id': task_id,
'conversation_id': rec.conversation_id,
'has_running_sub_agents': False,
'has_running_background_commands': False,
'has_running_multi_agent': False,
'multi_agent_reloaded': reload_ok,
'messages_count': messages_count,
}, room=f"user_{username}")
except Exception as exc:
debug_log(f"[TaskCancel] force_stop 发送 task_stopped 事件失败: {exc}")
return True
@staticmethod @staticmethod
def _normalize_runtime_pending_queue(raw_queue: Any) -> List[Dict[str, Any]]: def _normalize_runtime_pending_queue(raw_queue: Any) -> List[Dict[str, Any]]:
@ -766,10 +568,8 @@ class TaskManager:
# ---- internal helpers ---- # ---- internal helpers ----
def _cleanup_background_tasks(self, rec: TaskRecord, terminal: Optional[Any]) -> bool: def _cleanup_background_tasks(self, rec: TaskRecord, terminal: Optional[Any]) -> bool:
"""清理指定任务/对话下的所有后台子智能体和后台命令,返回是否清理到任何任务。""" """清理指定任务/对话下的所有后台子智能体和后台命令,返回是否清理到任何任务。"""
debug_log(f"[TaskCancel][stop_debug] _cleanup_background_tasks 被调! conv={rec.conversation_id}")
has_running_background = False has_running_background = False
if not terminal or not rec.conversation_id: if not terminal or not rec.conversation_id:
debug_log(f"[TaskCancel][stop_debug] _cleanup_background_tasks 跳过terminal或conv 不存在")
return has_running_background return has_running_background
sub_agent_manager = getattr(terminal, 'sub_agent_manager', None) sub_agent_manager = getattr(terminal, 'sub_agent_manager', None)
@ -782,7 +582,6 @@ class TaskManager:
status = task_info.get('status') status = task_info.get('status')
if status not in SUB_AGENT_TERMINAL_STATUSES.union({"terminated"}): if status not in SUB_AGENT_TERMINAL_STATUSES.union({"terminated"}):
has_running_background = True has_running_background = True
debug_log(f"[TaskCancel][stop_debug] 调用 terminate_sub_agent: task_id={task_info.get('task_id')}, status={status}")
try: try:
sub_agent_manager.terminate_sub_agent(task_id=task_info.get('task_id')) sub_agent_manager.terminate_sub_agent(task_id=task_info.get('task_id'))
except Exception as exc: except Exception as exc:

View File

@ -331,8 +331,10 @@
:multi-agent-mode="multiAgentMode" :multi-agent-mode="multiAgentMode"
:has-running-task="composerBusy || anyWorkspaceHasRunningTask" :has-running-task="composerBusy || anyWorkspaceHasRunningTask"
:main-chat-idle="mainChatIdle" :main-chat-idle="mainChatIdle"
:active-sub-agent-count="activeSubAgentCount"
:avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null" :avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null"
@restore-user-question="restoreUserQuestionDialog" @restore-user-question="restoreUserQuestionDialog"
@stop-all-sub-agents="handleStopAllSubAgents"
@update:input-message="inputSetMessage" @update:input-message="inputSetMessage"
@input-change="handleInputChange" @input-change="handleInputChange"
@input-focus="handleInputFocus" @input-focus="handleInputFocus"

View File

@ -252,6 +252,19 @@ export const computed = {
this.compressing this.compressing
); );
}, },
/**
* running + idle failed/completed/terminated/timeout
* x
*/
activeSubAgentCount() {
const subAgentStore = useSubAgentStore();
const list = Array.isArray(subAgentStore.subAgents) ? subAgentStore.subAgents : [];
const TERMINAL = new Set(['completed', 'failed', 'timeout', 'terminated']);
return list.filter((s: any) => {
const status = String(s?.status || '').toLowerCase();
return !TERMINAL.has(status);
}).length;
},
/** /**
* *
* composerBusy composerBusy=true * composerBusy composerBusy=true

View File

@ -36,7 +36,6 @@ export const sendMethods = {
const mainIdle = typeof this.mainChatIdle === 'function' ? this.mainChatIdle : ( const mainIdle = typeof this.mainChatIdle === 'function' ? this.mainChatIdle : (
!this.streamingUi && !this.stopRequested && !this.compressionInProgress && !this.compressing !this.streamingUi && !this.stopRequested && !this.compressionInProgress && !this.compressing
); );
console.log('[STOP_DEBUG] handleSendOrStop 状态 → composerBusy:', this.composerBusy, 'mainIdle:', mainIdle, 'hasText:', hasText, 'hasMedia:', hasMedia, 'waitingForSubAgent:', this.waitingForSubAgent);
if (this.composerBusy && mainIdle && hasText) { if (this.composerBusy && mainIdle && hasText) {
// 如果有 pending 问题(子智能体询问主智能体),仍走问答路径,不走直接发送 // 如果有 pending 问题(子智能体询问主智能体),仍走问答路径,不走直接发送
if (Array.isArray(this.pendingUserQuestions) && this.pendingUserQuestions.length > 0) { if (Array.isArray(this.pendingUserQuestions) && this.pendingUserQuestions.length > 0) {
@ -49,7 +48,7 @@ export const sendMethods = {
} }
return; return;
} }
console.log('[STOP_DEBUG] 主对话空闲但后台子智能体在跑,直接发送新消息触发主智能体下一轮'); // 主对话空闲但后台任务在跑:直接发送新消息触发主智能体下一轮
this.sendMessage(); this.sendMessage();
return; return;
} }
@ -426,16 +425,9 @@ export const sendMethods = {
return; return;
} }
// 多智能体模式下stopRequested 不阻止第二次点击——第二次点击是强制停止子智能体 // 停止按钮现在只停主智能体,与后台任务无关。
const isMultiAgent = !!this.multiAgentMode; // canStop 判断只看主智能体是否在 streaming。多次点击被 stopRequested 抖动拦截。
const canStop = isMultiAgent const canStop = this.streamingUi && !this.stopRequested;
? (this.composerBusy || this.stopRequested)
: (this.composerBusy && !this.stopRequested);
// 多智能体模式下如果主智能体已经停了stopRequested=true第二次点击是 force stop
const isForceStop = isMultiAgent && this.stopRequested;
console.log('[STOP_DEBUG] stopTask 状态快照 → isMultiAgent:', isMultiAgent, 'composerBusy:', this.composerBusy, 'stopRequested:', this.stopRequested, 'taskInProgress:', this.taskInProgress, 'canStop:', canStop, 'isForceStop:', isForceStop);
goalModeDebugLog('stopTask:entry', { goalModeDebugLog('stopTask:entry', {
composerBusy: this.composerBusy, composerBusy: this.composerBusy,
@ -443,8 +435,6 @@ export const sendMethods = {
taskInProgress: this.taskInProgress, taskInProgress: this.taskInProgress,
streamingUi: this.streamingUi, streamingUi: this.streamingUi,
canStop, canStop,
isForceStop,
isMultiAgent,
currentTaskId: this.currentTaskId, currentTaskId: this.currentTaskId,
}); });
if (!canStop) { if (!canStop) {
@ -470,30 +460,12 @@ export const sendMethods = {
const taskStore = useTaskStore(); const taskStore = useTaskStore();
if (taskStore.currentTaskId) { if (taskStore.currentTaskId) {
await taskStore.cancelTask(isForceStop); await taskStore.cancelTask();
} }
// 等待后端确认轮询继续task_stopped 事件到达后会由 taskStore 自动停止轮询 // 等待后端确认轮询继续task_stopped 事件到达后会由 taskStore 自动停止轮询
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
// 多智能体模式强制停止后,直接释放所有状态,等待 task_stopped 事件处理重载
if (isForceStop) {
this.clearPendingTools('user_stop');
this.streamingMessage = false;
this.taskInProgress = false;
this.forceUnlockMonitor('user_stop');
if (typeof this.clearProcessedEvents === 'function') {
this.clearProcessedEvents();
}
this.stopMultiAgentTaskProbe();
this.stopWaitingTaskProbe();
const lastMessage = this.messages[this.messages.length - 1];
if (lastMessage && lastMessage.role === 'assistant') {
lastMessage.awaitingFirstContent = false;
lastMessage.generatingLabel = '';
}
console.log('[DEBUG_AWAITING] stopTask force stop 清理完成');
} else {
const shouldKeepBusy = const shouldKeepBusy =
['running', 'pending', 'cancel_requested', 'canceled'].includes(String(taskStore.taskStatus)); ['running', 'pending', 'cancel_requested', 'canceled'].includes(String(taskStore.taskStatus));
@ -516,26 +488,10 @@ export const sendMethods = {
// 清理assistant消息的等待动画状态 // 清理assistant消息的等待动画状态
const lastMessage = this.messages[this.messages.length - 1]; const lastMessage = this.messages[this.messages.length - 1];
const before = {
hasLastMessage: !!lastMessage,
role: lastMessage?.role,
awaitingFirstContent: lastMessage?.awaitingFirstContent,
generatingLabel: lastMessage?.generatingLabel
};
if (lastMessage && lastMessage.role === 'assistant') { if (lastMessage && lastMessage.role === 'assistant') {
lastMessage.awaitingFirstContent = false; lastMessage.awaitingFirstContent = false;
lastMessage.generatingLabel = ''; lastMessage.generatingLabel = '';
} }
console.log('[DEBUG_AWAITING] stopTask 清理完成', {
before,
after: {
awaitingFirstContent: lastMessage?.awaitingFirstContent,
generatingLabel: lastMessage?.generatingLabel
}
});
}
} catch (error) { } catch (error) {
console.error('[Message] 取消任务失败:', error); console.error('[Message] 取消任务失败:', error);
const { useTaskStore } = await import('../../../stores/task'); const { useTaskStore } = await import('../../../stores/task');
@ -569,7 +525,7 @@ export const sendMethods = {
this.uiPushToast({ this.uiPushToast({
title: '停止请求已发送', title: '停止请求已发送',
message: '若任务未停止,请再次点击停止按钮', message: '若主对话未停止,请稍候;后台任务可通过状态栏单独停止',
type: 'info' type: 'info'
}); });
} finally { } finally {

View File

@ -180,5 +180,55 @@ export const modeMethods = {
}, },
async toggleThinkingMode() { async toggleThinkingMode() {
await this.handleCycleRunMode(); await this.handleCycleRunMode();
},
async handleStopAllSubAgents() {
const isMultiAgent = !!this.multiAgentMode;
const mode = isMultiAgent ? 'soft_stop' : 'terminate';
const title = isMultiAgent ? '是否暂停所有子智能体?' : '是否终结所有子智能体?';
const message = isMultiAgent
? '所有正在运行的子智能体将停止工作并变为空闲状态。取消表示不执行操作。'
: '所有后台子智能体将被强制终止。取消表示不执行操作。';
const confirmText = isMultiAgent ? '暂停' : '终结';
let proceed = false;
try {
const uiStore = (await import('../../../stores/ui')).useUiStore();
proceed = await uiStore.requestConfirm({
title,
message,
confirmText,
cancelText: '取消',
confirmVariant: 'danger',
closeOnBackdrop: true
});
} catch (error) {
console.error('[stopAllSubAgents] 弹窗异常:', error);
return;
}
if (!proceed) return;
try {
const { useSubAgentStore } = await import('../../../stores/subAgent');
const store = useSubAgentStore();
const result = await store.stopAllAgents(mode);
if (!result.success) {
this.uiPushToast({
title: '停止子智能体失败',
message: result.error || '请重试',
type: 'error'
});
return;
}
this.uiPushToast({
title: isMultiAgent ? '子智能体已暂停' : '子智能体已终结',
message: `已处理 ${result.stoppedCount || 0} 个子智能体`,
type: 'info'
});
} catch (error: any) {
console.error('[stopAllSubAgents] 调用失败:', error);
this.uiPushToast({
title: '停止子智能体失败',
message: error?.message || String(error),
type: 'error'
});
}
} }
}; };

View File

@ -177,6 +177,14 @@
> >
等待回答问题 等待回答问题
</button> </button>
<button
v-if="(activeSubAgentCount || 0) > 0"
type="button"
class="floating-project-status__notice floating-project-status__notice--button"
@click.stop="$emit('stop-all-sub-agents')"
>
{{ activeSubAgentCount }} 个子智能体运行中
</button>
<button <button
v-if="terminalCount > 0" v-if="terminalCount > 0"
type="button" type="button"
@ -549,7 +557,8 @@ const emit = defineEmits([
'toggle-goal-mode', 'toggle-goal-mode',
'open-goal-dialog', 'open-goal-dialog',
'open-git-changes-panel', 'open-git-changes-panel',
'toggle-agent-mode' 'toggle-agent-mode',
'stop-all-sub-agents'
]); ]);
const props = defineProps<{ const props = defineProps<{
@ -625,6 +634,8 @@ const props = defineProps<{
* 多智能体模式下若主对话空闲输入栏应处于正常发送状态不是停止状态 * 多智能体模式下若主对话空闲输入栏应处于正常发送状态不是停止状态
*/ */
mainChatIdle?: boolean; mainChatIdle?: boolean;
/** 非终态子智能体数量用于状态栏“x 个子智能体运行中”按钮显示 */
activeSubAgentCount?: number;
hasPendingRuntimeGuidance?: boolean; hasPendingRuntimeGuidance?: boolean;
terminalCount?: number; terminalCount?: number;
hostMode?: boolean; hostMode?: boolean;
@ -841,7 +852,8 @@ const floatingStatusVisible = computed(() => {
!!projectGitSummaryForRender.value || !!projectGitSummaryForRender.value ||
!!props.goalRunning || !!props.goalRunning ||
!!props.goalModeArmed || !!props.goalModeArmed ||
(!!props.userQuestionMinimized && Number(props.pendingUserQuestionCount || 0) > 0) (!!props.userQuestionMinimized && Number(props.pendingUserQuestionCount || 0) > 0) ||
(Number(props.activeSubAgentCount || 0) > 0)
); );
}); });
@ -2404,34 +2416,20 @@ const hasComposerContent = computed(() => {
}); });
const showStopIcon = computed(() => { const showStopIcon = computed(() => {
// // streaming/
// streaming mainChatIdle=false // x LeftPanel
if (props.multiAgentMode) { return !!props.streamingMessage && !hasComposerContent.value;
if (props.mainChatIdle) {
return false;
}
return !hasComposerContent.value;
}
return props.streamingMessage && !hasComposerContent.value;
}); });
const sendButtonDisabled = computed(() => { const sendButtonDisabled = computed(() => {
if (!props.isConnected) { if (!props.isConnected) {
return true; return true;
} }
// // streaming stopTask
if (props.multiAgentMode && props.mainChatIdle) {
if (props.inputLocked) {
return true;
}
if (props.mediaUploading) {
return true;
}
return !hasComposerContent.value;
}
if (props.streamingMessage) { if (props.streamingMessage) {
return false; return false;
} }
//
if (props.inputLocked) { if (props.inputLocked) {
return true; return true;
} }

View File

@ -96,6 +96,16 @@
aria-hidden="true" aria-hidden="true"
></span> ></span>
</button> </button>
<button
v-if="panelMode === 'backgroundCommands' && runningBackgroundCommandCount > 0"
type="button"
class="background-stop-btn"
title="终止所有后台指令"
@click.stop="handleStopAllCommands"
>
<span class="icon icon-sm" :style="iconStyle('stop')" aria-hidden="true"></span>
<span class="background-stop-label">停止</span>
</button>
</div> </div>
</transition> </transition>
</div> </div>
@ -415,6 +425,20 @@ const openBackgroundCommand = (command: any) => {
backgroundCommandStore.openCommand(command); backgroundCommandStore.openCommand(command);
}; };
const BG_COMMAND_TERMINAL = new Set(['completed', 'failed', 'timeout', 'cancelled']);
const runningBackgroundCommandCount = computed(() => {
return (backgroundCommands.value || []).filter((c: any) => {
const s = String(c?.status || '').toLowerCase();
return !BG_COMMAND_TERMINAL.has(s);
}).length;
});
const handleStopAllCommands = async () => {
const result = await backgroundCommandStore.stopAllCommands();
if (!result.success) {
console.error('[LeftPanel] 批量停止后台指令失败:', result.error);
}
};
const formatBackgroundCommandMeta = (command: any) => { const formatBackgroundCommandMeta = (command: any) => {
const code = command?.return_code; const code = command?.return_code;
if (code === null || code === undefined) { if (code === null || code === undefined) {

View File

@ -1642,59 +1642,22 @@ export async function initializeLegacySocket(ctx: any) {
// 任务停止 // 任务停止
ctx.socket.on('task_stopped', (data) => { ctx.socket.on('task_stopped', (data) => {
socketLog('任务已停止:', data.message); socketLog('任务已停止:', data.message);
console.log('[STOP_DEBUG] 收到 task_stopped 事件:', JSON.stringify({
message: data.message,
reason: data.reason,
task_id: data.task_id,
has_running_sub_agents: data.has_running_sub_agents,
has_running_background_commands: data.has_running_background_commands,
has_running_multi_agent: data.has_running_multi_agent,
multi_agent_reloaded: data.multi_agent_reloaded,
multiAgentMode: ctx.multiAgentMode,
stopRequested_before: ctx.stopRequested,
taskInProgress_before: ctx.taskInProgress,
composerBusy: ctx.composerBusy
}));
const hasRunningSubAgents = !!data?.has_running_sub_agents; const hasRunningSubAgents = !!data?.has_running_sub_agents;
const hasRunningBackgroundCommands = !!data?.has_running_background_commands; const hasRunningBackgroundCommands = !!data?.has_running_background_commands;
const hasRunningBackground = hasRunningSubAgents || hasRunningBackgroundCommands; const hasRunningBackground = hasRunningSubAgents || hasRunningBackgroundCommands;
const hasRunningMultiAgent = !!data?.has_running_multi_agent; const hasRunningMultiAgent = !!data?.has_running_multi_agent;
const multiAgentReloaded = !!data?.multi_agent_reloaded; // 主对话已被停止。streaming、stopRequested 都重置。
// 如果仍有后台任务在跑,保留 taskInProgress=true 让对话在列表里仍显示“运行中”
// 多智能体模式下,第二下停止后后端已从文件恢复对话,前端需要重新加载对话内容 // (仅影响 UI 标识,不影响输入栏可用性——输入栏只看 streamingMessage
if (multiAgentReloaded && ctx.currentConversationId) { const stillHasBackground = hasRunningBackground || hasRunningMultiAgent;
console.log('[DEBUG] 多智能体对话已从文件恢复,前端重新加载'); ctx.streamingMessage = false;
// 触发前端重新加载对话内容(加载已保存的完整消息列表,丢弃半成品状态) ctx.stopRequested = false;
if (typeof ctx.loadConversation === 'function') {
ctx.loadConversation(ctx.currentConversationId, { force: true });
} else {
// 回退:直接调 API 重新加载
fetch(`/api/conversations/${ctx.currentConversationId}/load`, { method: 'PUT' })
.then(r => r.json())
.then(result => {
if (result.success && result.messages_count !== undefined) {
console.log('[DEBUG] 对话重新加载成功,消息数:', result.messages_count);
}
})
.catch(err => console.error('[DEBUG] 对话重新加载失败:', err));
}
}
// 多智能体模式下,主任务停止即可恢复输入区,不因子智能体后台运行保持停止按钮
if (ctx.multiAgentMode || !hasRunningBackground) {
ctx.taskInProgress = false;
} else {
ctx.waitingForSubAgent = hasRunningSubAgents; ctx.waitingForSubAgent = hasRunningSubAgents;
ctx.waitingForBackgroundCommand = hasRunningBackgroundCommands; ctx.waitingForBackgroundCommand = hasRunningBackgroundCommands;
if (typeof ctx.uiPushToast === 'function') { ctx.taskInProgress = stillHasBackground;
ctx.uiPushToast({ if (typeof ctx.scheduleResetAfterTask === 'function') {
title: '智能体已停止',
message: '再次按下停止按钮以结束后台任务',
type: 'info'
});
}
}
ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true }); ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true });
}
}); });
// 任务完成重点更新Token统计 // 任务完成重点更新Token统计

View File

@ -184,6 +184,24 @@ export const useSubAgentStore = defineStore('subAgent', {
this.stoppingTaskIds = next; this.stoppingTaskIds = next;
} }
}, },
async stopAllAgents(mode: 'terminate' | 'soft_stop'): Promise<{ success: boolean; stoppedCount?: number; error?: string }> {
try {
const resp = await fetch('/api/sub_agents/stop_all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
await this.fetchSubAgents();
return { success: true, stoppedCount: data?.data?.stopped_count || 0 };
} catch (error: any) {
const message = error?.message || String(error);
return { success: false, error: message };
}
},
stripConversationPrefix(conversationId: string) { stripConversationPrefix(conversationId: string) {
if (!conversationId) return ''; if (!conversationId) return '';
return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId; return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId;

View File

@ -538,19 +538,17 @@ export const useTaskStore = defineStore('task', {
this.currentTaskId = null; // 清除任务 ID this.currentTaskId = null; // 清除任务 ID
}, },
async cancelTask(forceStop: boolean = false) { async cancelTask() {
if (!this.currentTaskId) { if (!this.currentTaskId) {
return; return;
} }
try { try {
debugLog('[Task] 取消任务:', this.currentTaskId, 'forceStop:', forceStop); debugLog('[Task] 取消任务:', this.currentTaskId);
console.log('[STOP_DEBUG] cancelTask 发送请求 → taskId:', this.currentTaskId, 'forceStop:', forceStop);
const response = await fetch(`/api/tasks/${this.currentTaskId}/cancel`, { const response = await fetch(`/api/tasks/${this.currentTaskId}/cancel`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' }
body: JSON.stringify({ force_stop: forceStop })
}); });
if (!response.ok) { if (!response.ok) {

View File

@ -998,3 +998,28 @@
.panel-menu button.active { .panel-menu button.active {
background: var(--theme-tab-active); background: var(--theme-tab-active);
} }
/* 后台指令停止按钮 */
.background-stop-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--claude-border);
border-radius: 10px;
background: transparent;
color: var(--state-error);
font-size: 12px;
line-height: 1.2;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.background-stop-btn:hover {
background: color-mix(in srgb, var(--state-error) 12%, transparent);
}
.background-stop-label {
font-weight: 500;
}