fix(goal): 目标状态改为对话级存储,修复多对话目标串扰与进度事件串台

- goal_state.json(工作区级单实例) → goal_states/<conversation_id>.json(对话级)
- 移除 start_conversation_id 冗余字段与“工作区唯一目标”“压缩改id”等过时注释
- goal 进度/审核事件快照补 conversation_id,前端现有过滤逻辑自动生效
- 清理 tasks/media.py、tasks/helpers.py 未使用的残留 import
This commit is contained in:
JOJO 2026-08-19 13:53:51 +08:00
parent 4b381f3206
commit 17c49f6258
7 changed files with 100 additions and 56 deletions

View File

@ -1,23 +1,28 @@
"""目标模式Goal Mode工作区级状态管理。 """目标模式Goal Mode对话级状态管理。
每个工作区/项目最多存在一个活动目标状态落盘到 `{data_dir}/goal_state.json` 每个对话各自持有独立的目标状态互不影响状态落盘到
以便在切换对话对话压缩conversation_id 变化进程重启后仍能维持 `{data_dir}/goal_states/<conversation_id>.json`在切换对话对话压缩
进程重启后仍能维持压缩不会改变 conversation_id压缩 handoff 重入时
由主循环入口按当前 conversation_id 重新加载本对话状态并续注提示词
设计要点 设计要点
- 不依赖 web_terminal只接受一个 data_dir便于单测与复用 - 不依赖 web_terminal只接受 data_dir conversation_id便于单测与复用
- token 基线对齐工作区级累计 `{data_dir}/token_totals.json`input/output/total - token 基线对齐工作区级累计 `{data_dir}/token_totals.json`input/output/total
注意该累计是整个工作区所有对话共享的多对话并发运行时 max_tokens
边界判定是近似值可能因其他对话的消耗而提前触发
- review_history 保存历轮 {main_output, review_reply}用于构建交叉结构审核文本 - review_history 保存历轮 {main_output, review_reply}用于构建交叉结构审核文本
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import re
import time import time
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
GOAL_STATE_FILENAME = "goal_state.json" GOAL_STATES_DIRNAME = "goal_states"
# 状态机 # 状态机
STATUS_RUNNING = "running" STATUS_RUNNING = "running"
@ -36,6 +41,16 @@ REVIEW_MODE_ACTIVE = "active"
PathLike = Union[str, Path] PathLike = Union[str, Path]
_SAFE_CONVERSATION_ID = re.compile(r"^[A-Za-z0-9_-]+$")
def _validate_conversation_id(conversation_id: str) -> str:
"""conversation_id 用作状态文件名,必须是安全字符,防止路径穿越。"""
cid = str(conversation_id or "").strip()
if not cid or not _SAFE_CONVERSATION_ID.match(cid):
raise ValueError(f"非法 conversation_id: {conversation_id!r}")
return cid
def _empty_state() -> Dict[str, Any]: def _empty_state() -> Dict[str, Any]:
return { return {
@ -45,7 +60,6 @@ def _empty_state() -> Dict[str, Any]:
"review_mode": REVIEW_MODE_READONLY, "review_mode": REVIEW_MODE_READONLY,
"max_turns": 5, "max_turns": 5,
"max_tokens": None, "max_tokens": None,
"start_conversation_id": None,
"started_at": None, "started_at": None,
"turn_count": 0, "turn_count": 0,
"token_baseline": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, "token_baseline": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
@ -58,21 +72,22 @@ def _empty_state() -> Dict[str, Any]:
class GoalStateManager: class GoalStateManager:
"""工作区级目标状态。一个实例对应一个工作区的 goal_state.json。""" """对话级目标状态。一个实例对应一个对话的 goal_states/<conversation_id>.json。"""
def __init__(self, data_dir: PathLike): def __init__(self, data_dir: PathLike, conversation_id: str):
self.data_dir = Path(data_dir).expanduser() self.data_dir = Path(data_dir).expanduser()
self.conversation_id = _validate_conversation_id(conversation_id)
self.state: Dict[str, Any] = self.load() self.state: Dict[str, Any] = self.load()
# ------------------------------------------------------------------ 路径/持久化 # ------------------------------------------------------------------ 路径/持久化
def _path(self) -> Path: def _path(self) -> Path:
return self.data_dir / GOAL_STATE_FILENAME return self.data_dir / GOAL_STATES_DIRNAME / f"{self.conversation_id}.json"
@classmethod @classmethod
def load_from(cls, data_dir: PathLike) -> "GoalStateManager": def load_from(cls, data_dir: PathLike, conversation_id: str) -> "GoalStateManager":
"""便捷构造:等价于 GoalStateManager(data_dir)。""" """便捷构造:等价于 GoalStateManager(data_dir, conversation_id)。"""
return cls(data_dir) return cls(data_dir, conversation_id)
def load(self) -> Dict[str, Any]: def load(self) -> Dict[str, Any]:
path = self._path() path = self._path()
@ -112,7 +127,6 @@ class GoalStateManager:
review_mode: str, review_mode: str,
max_turns: Optional[int], max_turns: Optional[int],
max_tokens: Optional[int], max_tokens: Optional[int],
conversation_id: Optional[str],
token_baseline: Optional[Dict[str, int]] = None, token_baseline: Optional[Dict[str, int]] = None,
tool_call_baseline: int = 0, tool_call_baseline: int = 0,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@ -131,7 +145,6 @@ class GoalStateManager:
"review_mode": rm, "review_mode": rm,
"max_turns": int(max_turns) if max_turns else None, "max_turns": int(max_turns) if max_turns else None,
"max_tokens": int(max_tokens) if max_tokens else None, "max_tokens": int(max_tokens) if max_tokens else None,
"start_conversation_id": conversation_id,
"started_at": time.time(), "started_at": time.time(),
"turn_count": 0, "turn_count": 0,
"token_baseline": baseline, "token_baseline": baseline,
@ -194,8 +207,15 @@ class GoalStateManager:
return deepcopy(self.state) return deepcopy(self.state)
def clear(self) -> None: def clear(self) -> None:
"""完全清除目标状态(用户取消)""" """完全清除本对话的目标状态。"""
self.state = _empty_state() self.state = _empty_state()
path = self._path()
try:
if path.exists():
path.unlink()
return
except OSError:
pass
self.save() self.save()
# ------------------------------------------------------------------ 边界判定 # ------------------------------------------------------------------ 边界判定
@ -267,6 +287,7 @@ class GoalStateManager:
except Exception: except Exception:
pass pass
return { return {
"conversation_id": self.conversation_id,
"goal": self.get_goal(), "goal": self.get_goal(),
"status": self.state.get("status"), "status": self.state.get("status"),
"turn_count": self.get_turn(), "turn_count": self.get_turn(),

View File

@ -1868,12 +1868,11 @@ async def handle_task_with_sender(
goal_text=message, goal_text=message,
current_tool_calls=0, current_tool_calls=0,
) )
# 本标记只表示“本次用户显式开启目标模式”。目标状态已写入 # 本标记只表示“本次用户显式开启目标模式”,写入对话级目标状态后
# goal_state.json 后必须立即消费掉,否则自动深层压缩后的递归 # 必须立即消费掉:自动深层压缩会递归重入 handle_task_with_sender
# handoff 会再次进入 handle_task_with_sender把压缩引导语误当作 # 若不消费,压缩引导语会被误当作新目标再次启动目标模式。
# 新目标并重置目标文本、轮数、token、工具次数和开始时间。
setattr(web_terminal, "_goal_mode_requested", False) setattr(web_terminal, "_goal_mode_requested", False)
if goal_is_active(workspace): if goal_is_active(workspace, conversation_id):
inject_goal_prompt( inject_goal_prompt(
web_terminal=web_terminal, web_terminal=web_terminal,
messages=messages, messages=messages,
@ -1884,6 +1883,7 @@ async def handle_task_with_sender(
web_terminal=web_terminal, web_terminal=web_terminal,
workspace=workspace, workspace=workspace,
sender=sender, sender=sender,
conversation_id=conversation_id,
total_tool_calls=0, total_tool_calls=0,
) )
except Exception as exc: except Exception as exc:
@ -1969,6 +1969,7 @@ async def handle_task_with_sender(
web_terminal=web_terminal, web_terminal=web_terminal,
workspace=workspace, workspace=workspace,
sender=sender, sender=sender,
conversation_id=conversation_id,
total_tool_calls=total_tool_calls, total_tool_calls=total_tool_calls,
) )
except Exception as exc: except Exception as exc:
@ -2227,7 +2228,7 @@ async def handle_task_with_sender(
debug_log("没有工具调用,结束迭代") debug_log("没有工具调用,结束迭代")
# === 目标模式turn 结束拦截 === # === 目标模式turn 结束拦截 ===
try: try:
if goal_is_active(workspace): if goal_is_active(workspace, conversation_id):
goal_result = await handle_goal_after_turn( goal_result = await handle_goal_after_turn(
web_terminal=web_terminal, web_terminal=web_terminal,
workspace=workspace, workspace=workspace,
@ -2311,11 +2312,12 @@ async def handle_task_with_sender(
# 更新统计 # 更新统计
total_tool_calls += len(tool_calls) total_tool_calls += len(tool_calls)
try: try:
if goal_is_active(workspace): if goal_is_active(workspace, conversation_id):
emit_goal_progress( emit_goal_progress(
web_terminal=web_terminal, web_terminal=web_terminal,
workspace=workspace, workspace=workspace,
sender=sender, sender=sender,
conversation_id=conversation_id,
total_tool_calls=total_tool_calls, total_tool_calls=total_tool_calls,
) )
except Exception as exc: except Exception as exc:

View File

@ -1,7 +1,8 @@
"""目标模式Goal Mode在 Web 任务主循环中的编排逻辑。 """目标模式Goal Mode在 Web 任务主循环中的编排逻辑。
将启动提示词注入turn 结束后的审核/续命/停止判定集中在此 目标状态是对话级的 modules/goal_state_manager.py每个对话独立持有
尽量减少对 chat_flow_task_main.py 的侵入 自己的目标多对话并发互不影响本模块将启动提示词注入turn 结束后的
审核/续命/停止判定集中在此尽量减少对 chat_flow_task_main.py 的侵入
""" """
from __future__ import annotations from __future__ import annotations
@ -57,17 +58,18 @@ def maybe_start_goal(
*, *,
web_terminal, web_terminal,
workspace, workspace,
conversation_id: Optional[str], conversation_id: str,
goal_text: str, goal_text: str,
current_tool_calls: int, current_tool_calls: int,
) -> bool: ) -> bool:
"""启动一个新目标。 """为本对话启动一个新目标。
本函数语义是本次用户显式开启目标模式而不是续接已有目标 目标状态是对话级的启动会覆盖本对话自己的旧目标状态running/done/stopped
因此必须覆盖工作区级 goal_state.json 中的旧目标状态包括 running/done/stopped review_historytoken 基线开始时间等不影响其他对话的目标
review_historytoken 基线开始时间等避免新对话复用上一轮目标
""" """
gsm = GoalStateManager(workspace.data_dir) if not conversation_id:
return False
gsm = GoalStateManager(workspace.data_dir, conversation_id)
cfg = load_personalization_config(workspace.data_dir) cfg = load_personalization_config(workspace.data_dir)
review_mode = cfg.get("goal_review_mode") or "readonly" review_mode = cfg.get("goal_review_mode") or "readonly"
end_conditions = cfg.get("goal_end_conditions") or ["max_turns"] end_conditions = cfg.get("goal_end_conditions") or ["max_turns"]
@ -78,7 +80,6 @@ def maybe_start_goal(
review_mode=review_mode, review_mode=review_mode,
max_turns=max_turns, max_turns=max_turns,
max_tokens=max_tokens, max_tokens=max_tokens,
conversation_id=conversation_id,
token_baseline=snapshot_token_baseline(web_terminal), token_baseline=snapshot_token_baseline(web_terminal),
tool_call_baseline=current_tool_calls, tool_call_baseline=current_tool_calls,
) )
@ -105,8 +106,13 @@ def inject_goal_prompt(
) )
def goal_is_active(workspace) -> bool: def goal_is_active(workspace, conversation_id: Optional[str]) -> bool:
return GoalStateManager(workspace.data_dir).is_active() if not conversation_id:
return False
try:
return GoalStateManager(workspace.data_dir, conversation_id).is_active()
except ValueError:
return False
def _emit_snapshot(sender, event: str, gsm: GoalStateManager, *, total_tokens: int, tool_calls: int, extra: Optional[Dict] = None): def _emit_snapshot(sender, event: str, gsm: GoalStateManager, *, total_tokens: int, tool_calls: int, extra: Optional[Dict] = None):
@ -126,11 +132,18 @@ def emit_goal_progress(
web_terminal, web_terminal,
workspace, workspace,
sender: Optional[Callable[[str, Dict[str, Any]], None]], sender: Optional[Callable[[str, Dict[str, Any]], None]],
conversation_id: Optional[str],
total_tool_calls: int = 0, total_tool_calls: int = 0,
extra: Optional[Dict[str, Any]] = None, extra: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
"""向前端广播当前目标模式进度,用于刷新后恢复与进度弹窗动态数字。""" """广播本对话的目标模式进度(快照自带 conversation_id前端按对话过滤
gsm = GoalStateManager(workspace.data_dir) 用于刷新后恢复与进度弹窗动态数字"""
if not conversation_id:
return
try:
gsm = GoalStateManager(workspace.data_dir, conversation_id)
except ValueError:
return
if not gsm.state: if not gsm.state:
return return
total_tokens = _load_token_total(web_terminal) total_tokens = _load_token_total(web_terminal)
@ -158,12 +171,17 @@ async def handle_goal_after_turn(
"""主模型某轮结束(无 tool_calls后的目标处理。 """主模型某轮结束(无 tool_calls后的目标处理。
返回 {'action': 'inactive'|'stop'|'done'|'continue', ...} 返回 {'action': 'inactive'|'stop'|'done'|'continue', ...}
- inactive当前无活动目标调用方照常结束本次任务 - inactive本对话无活动目标调用方照常结束本次任务
- stop目标因空转/边界停止调用方照常结束事件已发 - stop目标因空转/边界停止调用方照常结束事件已发
- done目标达成调用方照常结束事件已发 - done目标达成调用方照常结束事件已发
- continue已注入续命消息调用方应 continue 回主循环开下一轮 - continue已注入续命消息调用方应 continue 回主循环开下一轮
""" """
gsm = GoalStateManager(workspace.data_dir) if not conversation_id:
return {"action": "inactive"}
try:
gsm = GoalStateManager(workspace.data_dir, conversation_id)
except ValueError:
return {"action": "inactive"}
if not gsm.is_active(): if not gsm.is_active():
return {"action": "inactive"} return {"action": "inactive"}
@ -192,7 +210,7 @@ async def handle_goal_after_turn(
payload_text = gsm.build_review_payload_text(assistant_content) payload_text = gsm.build_review_payload_text(assistant_content)
if callable(sender): if callable(sender):
try: try:
sender("goal_review_progress", {"progress": {"stage": "start", "message": "开始审核"}}) sender("goal_review_progress", {"conversation_id": conversation_id, "progress": {"stage": "start", "message": "开始审核"}})
except Exception: except Exception:
pass pass
try: try:
@ -200,7 +218,7 @@ async def handle_goal_after_turn(
def _progress(progress: Dict[str, Any]) -> None: def _progress(progress: Dict[str, Any]) -> None:
if callable(sender): if callable(sender):
try: try:
sender("goal_review_progress", {"progress": progress}) sender("goal_review_progress", {"conversation_id": conversation_id, "progress": progress})
except Exception: except Exception:
pass pass
@ -240,9 +258,14 @@ async def handle_goal_after_turn(
return {"action": "continue", "message": message} return {"action": "continue", "message": message}
def stop_goal_user_cancel(*, web_terminal, workspace, sender, total_tool_calls: int = 0) -> bool: def stop_goal_user_cancel(*, web_terminal, workspace, sender, conversation_id: Optional[str], total_tool_calls: int = 0) -> bool:
"""用户停止任务时,若有活动目标则一并停止。返回是否有活动目标被停止。""" """用户停止任务时,若本对话有活动目标则一并停止。返回是否有活动目标被停止。"""
gsm = GoalStateManager(workspace.data_dir) if not conversation_id:
return False
try:
gsm = GoalStateManager(workspace.data_dir, conversation_id)
except ValueError:
return False
if not gsm.is_active(): if not gsm.is_active():
return False return False
total_tokens = _load_token_total(web_terminal) total_tokens = _load_token_total(web_terminal)

View File

@ -124,16 +124,16 @@ def create_task_api():
message_source = payload.get("message_source") message_source = payload.get("message_source")
max_iterations = payload.get("max_iterations") max_iterations = payload.get("max_iterations")
goal_mode = bool(payload.get("goal_mode")) goal_mode = bool(payload.get("goal_mode"))
# 用户显式发送非目标模式消息时,若工作区仍有上一轮残留的活动目标,先清理 # 用户显式发送非目标模式消息时,若本对话仍有残留的活动目标,先停止它
# 避免新对话错误继承旧目标状态 # 避免本对话错误延续旧目标。目标状态是对话级的,不影响其他对话
if not goal_mode: if not goal_mode and conversation_id:
try: try:
_terminal, workspace = get_user_resources(username, workspace_id, conversation_id=conversation_id) _terminal, workspace = get_user_resources(username, workspace_id, conversation_id=conversation_id)
if workspace: if workspace:
gsm = GoalStateManager(workspace.data_dir) gsm = GoalStateManager(workspace.data_dir, conversation_id)
if gsm.is_active(): if gsm.is_active():
gsm.mark_stopped("new_conversation") gsm.mark_stopped("new_message_without_goal")
debug_log(f"[Goal] 新任务未开启目标模式,清理工作区残留目标状态") debug_log(f"[Goal] 新消息未开启目标模式,停止本对话残留目标状态")
except Exception as exc: except Exception as exc:
debug_log(f"[Goal] 新任务清理残留目标状态失败: {exc}") debug_log(f"[Goal] 新任务清理残留目标状态失败: {exc}")
skill_context_messages: List[Dict[str, str]] = [] skill_context_messages: List[Dict[str, str]] = []
@ -287,11 +287,11 @@ def cancel_task_api(task_id: str):
try: try:
if ok and rec.workspace_id: if ok and rec.workspace_id:
_, workspace = get_user_resources(username, rec.workspace_id, conversation_id=rec.conversation_id) _, workspace = get_user_resources(username, rec.workspace_id, conversation_id=rec.conversation_id)
if workspace: if workspace and rec.conversation_id:
gsm = GoalStateManager(workspace.data_dir) gsm = GoalStateManager(workspace.data_dir, rec.conversation_id)
if gsm.is_active(): if gsm.is_active():
gsm.mark_stopped(REASON_USER_CANCEL) gsm.mark_stopped(REASON_USER_CANCEL)
debug_log(f"[Goal] 用户取消任务 {task_id},同步停止工作区目标模式") debug_log(f"[Goal] 用户取消任务 {task_id},同步停止本对话目标模式")
except Exception as exc: except Exception as exc:
debug_log(f"[Goal] 取消任务时停止目标模式失败: {exc}") debug_log(f"[Goal] 取消任务时停止目标模式失败: {exc}")
return jsonify({"success": True}) return jsonify({"success": True})

View File

@ -21,7 +21,6 @@ from server.state import stop_flags
from server.utils_common import debug_log, log_conn_diag from server.utils_common import debug_log, log_conn_diag
from utils.host_workspace_debug import write_host_workspace_debug from utils.host_workspace_debug import write_host_workspace_debug
from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME
from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL
from server.tasks.models import TaskRecord from server.tasks.models import TaskRecord

View File

@ -21,7 +21,6 @@ from server.state import stop_flags
from server.utils_common import debug_log, log_conn_diag from server.utils_common import debug_log, log_conn_diag
from utils.host_workspace_debug import write_host_workspace_debug from utils.host_workspace_debug import write_host_workspace_debug
from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME from config import DATA_DIR, WORKSPACE_SKILLS_DIRNAME
from modules.goal_state_manager import GoalStateManager, REASON_USER_CANCEL
SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S) SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n?", re.S)

View File

@ -285,11 +285,11 @@ class TaskManager:
if rec.workspace_id: if rec.workspace_id:
try: try:
_, workspace = get_user_resources(username, rec.workspace_id, conversation_id=rec.conversation_id) _, workspace = get_user_resources(username, rec.workspace_id, conversation_id=rec.conversation_id)
if workspace: if workspace and rec.conversation_id:
gsm = GoalStateManager(workspace.data_dir) gsm = GoalStateManager(workspace.data_dir, rec.conversation_id)
if gsm.is_active(): if gsm.is_active():
gsm.mark_stopped(REASON_USER_CANCEL) gsm.mark_stopped(REASON_USER_CANCEL)
debug_log(f"[Goal] 用户取消任务 {task_id},同步停止工作区目标模式") debug_log(f"[Goal] 用户取消任务 {task_id},同步停止本对话目标模式")
except Exception as exc: except Exception as exc:
debug_log(f"[Goal] 取消任务时停止目标模式失败: {exc}") debug_log(f"[Goal] 取消任务时停止目标模式失败: {exc}")