fix(项目管理): 确认死循环根治四件套

- fix1 _load_history: ASC LIMIT取最旧N条→DESC LIMIT取最新N条再倒回正序(会话超历史上限后LLM看不到用户刚答的确认,复读旧回复)
- fix2 delete_project描述: 去掉'先复述确认再调用'指令(平台确认门已弹窗给用户,LLM再问=死循环),改为直接confirm=true调用
- fix3 pause/resume跨产线状态兼容: 进行中不硬编码active(S_ACTIVE_LIKE含in_progress);暂停前原状态记审计detail,恢复时还原原状态(投标项目不会被改成状态机外的active)
- fix4 owner门禁降级: created_by='agent.*'的项目无人类字面owner→降级同机构人类可操作(否则永久拒绝)
This commit is contained in:
ymq 2026-09-01 15:29:59 +08:00
parent 493732e062
commit 7bb18d8839
2 changed files with 105 additions and 13 deletions

View File

@ -1077,8 +1077,8 @@ class AgentExecutor:
async def _t_delete_project(self, sor, p, pid): async def _t_delete_project(self, sor, p, pid):
confirm = p.get("confirm") confirm = p.get("confirm")
if str(confirm).lower() not in ("true", "1", "yes"): if str(confirm).lower() not in ("true", "1", "yes"):
return ("FAIL: 删除项目不可撤销。请先向用户复述项目名与后果," return ("FAIL: 参数错误,请直接带 confirm=true 调用(平台确认门会弹窗向用户最终确认,"
"取得明确确认后再带 confirm=true 调用本工具。") "无需你先向用户复述确认)。")
tpid, pname, err = await self._resolve_owned_project(sor, p) tpid, pname, err = await self._resolve_owned_project(sor, p)
if err: if err:
return f"FAIL: {err}" return f"FAIL: {err}"
@ -1453,6 +1453,10 @@ class AgentExecutor:
db = DBPools() db = DBPools()
async with db.sqlorContext("pipeline") as sor: async with db.sqlorContext("pipeline") as sor:
# 会话级隔离(web 多 tab 独立会话) # 会话级隔离(web 多 tab 独立会话)
# 注意(2026-09-01 修复):必须 ORDER BY created_at DESC LIMIT N 取「最新」N 条
# 再倒序回时间正序。历史用 ASC LIMIT 会取「最旧」N 条——会话消息超过
# history_limit 后,最近的提问(如 A/B/C 选项)被截掉,LLM 看不到刚问的
# 问题,按更旧的上下文答非所问(用户答 C,agent 复读 4 小时前的旧回复)。
if self.session_id: if self.session_id:
# 产线隔离(2026-08-31 修复跨产线串扰):各产线页面默认 tab 共用 # 产线隔离(2026-08-31 修复跨产线串扰):各产线页面默认 tab 共用
# session_id='default',历史若不按产线过滤,投标页会加载开发页的 # session_id='default',历史若不按产线过滤,投标页会加载开发页的
@ -1464,7 +1468,7 @@ class AgentExecutor:
if self.pipeline_id and not self.generic: if self.pipeline_id and not self.generic:
sql += "AND pipeline_id=${pl}$ " sql += "AND pipeline_id=${pl}$ "
params["pl"] = self.pipeline_id params["pl"] = self.pipeline_id
sql += "ORDER BY created_at ASC LIMIT ${lim}$" sql += "ORDER BY created_at DESC LIMIT ${lim}$"
recs = await sor.sqlExe(sql, params) recs = await sor.sqlExe(sql, params)
else: else:
# 项目隔离 # 项目隔离
@ -1473,19 +1477,20 @@ class AgentExecutor:
recs = await sor.sqlExe( recs = await sor.sqlExe(
"SELECT role, content FROM pipeline_conversations " "SELECT role, content FROM pipeline_conversations "
"WHERE iteration_id=${pid}$ AND created_by=${uid}$ " "WHERE iteration_id=${pid}$ AND created_by=${uid}$ "
"ORDER BY created_at ASC LIMIT ${lim}$", "ORDER BY created_at DESC LIMIT ${lim}$",
{"pid": pid, "uid": self.user_id or "", "lim": self.config.history_limit}, {"pid": pid, "uid": self.user_id or "", "lim": self.config.history_limit},
) )
else: else:
recs = await sor.sqlExe( recs = await sor.sqlExe(
"SELECT role, content FROM pipeline_conversations " "SELECT role, content FROM pipeline_conversations "
"WHERE created_by=${uid}$ " "WHERE created_by=${uid}$ "
"ORDER BY created_at ASC LIMIT ${lim}$", "ORDER BY created_at DESC LIMIT ${lim}$",
{"uid": self.user_id or "", "lim": self.config.history_limit}, {"uid": self.user_id or "", "lim": self.config.history_limit},
) )
if not recs: if not recs:
return [] return []
recs = list(reversed(recs)) # DESC 取出 → 倒回时间正序
# 过滤 tool_call JSON 污染(关键!) # 过滤 tool_call JSON 污染(关键!)
msgs = [] msgs = []

View File

@ -35,6 +35,11 @@ S_PAUSED = "paused" # 已暂停(暂停推进,PM 不再推进项目
S_COMPLETED = "completed" # 已完成 S_COMPLETED = "completed" # 已完成
S_ARCHIVED = "archived" # 已归档 S_ARCHIVED = "archived" # 已归档
# 「进行中」状态集合(跨产线状态兼容,2026-09-01):
# SDLC 产线用 active;投标/商机等产线用 in_progress。
# pause/resume 必须对两者都生效,否则非 SDLC 产线项目无法暂停。
S_ACTIVE_LIKE = {S_ACTIVE, "in_progress"}
def _get_db(): def _get_db():
db = DBPools() db = DBPools()
@ -125,7 +130,7 @@ async def check_project_owner(project_id, user_id, sor=None):
async def _check(_sor): async def _check(_sor):
recs = await _sor.sqlExe( recs = await _sor.sqlExe(
"SELECT created_by, name FROM sd_projects WHERE id=${pid}$", "SELECT created_by, name, org_id FROM sd_projects WHERE id=${pid}$",
{"pid": project_id}) {"pid": project_id})
await _sor.sqlExe("COMMIT", {}) await _sor.sqlExe("COMMIT", {})
if not recs: if not recs:
@ -133,6 +138,19 @@ async def check_project_owner(project_id, user_id, sor=None):
owner = getattr(recs[0], 'created_by', '') or '' owner = getattr(recs[0], 'created_by', '') or ''
if owner == user_id: if owner == user_id:
return True, '' return True, ''
# owner 降级(2026-09-01 修复):项目由 agent 创建(created_by='agent.*')时
# 没有任何人类是字面 owner,owner 门禁会永久拒绝,导致用户无法管理自己产线里
# agent 建的项目。此时降级为「同机构人类」:项目 org_id == 请求用户的 orgid 即放行。
if owner.startswith("agent."):
proj_org = str(getattr(recs[0], 'org_id', '') or '')
u = await _sor.sqlExe(
"SELECT orgid FROM users WHERE id=${uid}$", {"uid": user_id})
await _sor.sqlExe("COMMIT", {})
if u:
user_org = str(getattr(u[0], 'orgid', '') or '')
if user_org and user_org == proj_org:
return True, ''
return False, "项目由 agent 创建,仅同机构成员可操作"
return False, "仅项目 owner 可执行此操作" return False, "仅项目 owner 可执行此操作"
if sor is not None: if sor is not None:
@ -221,16 +239,85 @@ async def reopen_project(project_id, who=None, agent_id=None):
async def pause_project(project_id, who=None, agent_id=None): async def pause_project(project_id, who=None, agent_id=None):
"""暂停推进:active → paused。暂停后 PM 不再推进项目(不认领审核任务、不创建后续任务), """暂停推进:进行中 → paused。暂停后 PM 不再推进项目(不认领审核任务、不创建后续任务),
直到 resume_project 恢复。仅用户明确指令「暂停推进」时才调用——默认必须推进,无需指令。""" 直到 resume_project 恢复。仅用户明确指令「暂停推进」时才调用——默认必须推进,无需指令。
return await _transition(project_id, S_ACTIVE, S_PAUSED, 'pause',
who=who, agent_id=agent_id) 跨产线状态兼容(2026-09-01 修复):进行中状态不硬编码 active——
SDLC 产线是 active,投标/商机等产线是 in_progress,两者都可暂停。
暂停前的原状态记入审计 detail(prev_state=xxx),恢复时据此还原。
"""
if not project_id:
return False, "缺少 project_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R(TABLE, {'id': project_id})
if not recs:
await sor.sqlExe("COMMIT", {})
return False, "项目不存在"
cur = getattr(recs[0], 'status', '') or ''
if cur not in S_ACTIVE_LIKE:
await sor.sqlExe("COMMIT", {})
return False, f"无法暂停:项目状态 {cur}(仅 {sorted(S_ACTIVE_LIKE)} 可暂停)"
await sor.sqlExe(
f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW() "
"WHERE id=${pid}$ AND status=${from}$",
{"to": S_PAUSED, "pid": project_id, "from": cur})
recs2 = await sor.R(TABLE, {'id': project_id})
await sor.sqlExe("COMMIT", {})
if getattr(recs2[0], 'status', '') != S_PAUSED:
return False, f"状态迁移失败(CAS): 期望 to={S_PAUSED} 实际 status={getattr(recs2[0], 'status', '')}"
# 原状态记入审计(恢复时还原的依据)
await record_audit(project_id, TABLE, project_id, 'pause',
from_state=cur, to_state=S_PAUSED,
who=_normalize_role(who), agent_id=agent_id,
detail=f"prev_state={cur}", sor=sor)
return True, S_PAUSED
async def resume_project(project_id, who=None, agent_id=None): async def resume_project(project_id, who=None, agent_id=None):
"""恢复推进:paused → active。""" """恢复推进:paused → 原状态(优先还原暂停前的状态,跨产线兼容)。
return await _transition(project_id, S_PAUSED, S_ACTIVE, 'resume',
who=who, agent_id=agent_id) SDLC 项目还原为 active;投标/商机项目若暂停前是 in_progress 则还原为
in_progress(2026-09-01:原实现硬编码还原 active,会把投标项目
改成产线状态机里不存在的状态)。查不到暂停审计时兜底还原 active。
"""
if not project_id:
return False, "缺少 project_id"
db, dbname = _get_db()
async with db.sqlorContext(dbname) as sor:
recs = await sor.R(TABLE, {'id': project_id})
if not recs:
await sor.sqlExe("COMMIT", {})
return False, "项目不存在"
cur = getattr(recs[0], 'status', '') or ''
if cur != S_PAUSED:
await sor.sqlExe("COMMIT", {})
return False, f"无法恢复:项目状态 {cur}(仅 {S_PAUSED} 可恢复)"
# 查最近一次暂停审计,还原暂停前状态
restore_to = S_ACTIVE
audits = await sor.sqlExe(
"SELECT detail FROM audit_log WHERE tenant_id=${pid}$ AND entity=${e}$ "
"AND action='pause' ORDER BY created_at DESC LIMIT 1",
{"pid": project_id, "e": TABLE})
await sor.sqlExe("COMMIT", {})
if audits:
d = getattr(audits[0], 'detail', '') or ''
if d.startswith("prev_state="):
prev = d[len("prev_state="):].strip()
if prev in S_ACTIVE_LIKE:
restore_to = prev
await sor.sqlExe(
f"UPDATE {TABLE} SET status=${{to}}$, updated_at=NOW() "
"WHERE id=${pid}$ AND status=${from}$",
{"to": restore_to, "pid": project_id, "from": S_PAUSED})
recs2 = await sor.R(TABLE, {'id': project_id})
await sor.sqlExe("COMMIT", {})
if getattr(recs2[0], 'status', '') != restore_to:
return False, f"状态迁移失败(CAS): 期望 to={restore_to} 实际 status={getattr(recs2[0], 'status', '')}"
await record_audit(project_id, TABLE, project_id, 'resume',
from_state=S_PAUSED, to_state=restore_to,
who=_normalize_role(who), agent_id=agent_id, sor=sor)
return True, restore_to
async def set_project_state(project_id, from_state, to_state, async def set_project_state(project_id, from_state, to_state,