fix(chat): 修复子智能体完成通知不实时显示,压缩残留锁死输入与切换

- 通知:inject_runtime_user_message 不再把子智能体任务 id 透传为事件顶层
  task_id(sender setdefault 不覆盖,前端任务归属守卫丢弃事件;socket 通道
 在轮询模式下不渲染,双通道断头导致只能刷新后从历史看到)。后台指令因透传
 的是 command_id 而不受影响
- 通知:修复 _persist_and_echo_preceding_notice 回显写在 except 分支内,
 批量通知前 N-1 条从不实时回显
- 压缩:移除「压缩中切换/新建对话需确认并取消压缩」拦截(多对话独立运行后
 无存在意义)
- 压缩:新增 compressionConversationId,输入/发送/停止锁按对话隔离,/new
 新建页不再被其他对话的压缩状态锁定
- 压缩:metadata 记录 compression_pid,status/compression_status 读取时按
 pid 懒清理进程重启后的残留 in_progress 标记;run_deep_compression 异常时
 自动清理标记
This commit is contained in:
JOJO 2026-08-12 22:27:31 +08:00
parent 34bb5d61cc
commit 16900b0a20
16 changed files with 180 additions and 93 deletions

View File

@ -495,6 +495,10 @@ def _persist_and_echo_preceding_notice(
if cm is not None:
cm.add_conversation("user", message, metadata=metadata)
except Exception as exc:
debug_log(f"[CompletionNotice] 前置通知写入历史失败: {exc}")
# socketio 回显(在线客户端即时可见);轮询客户端由后续任务事件流回放,
# 前端按消息内容 dedup两条通道不会双显。
try:
echo_payload = {
"message": message,
"conversation_id": conversation_id,
@ -505,7 +509,7 @@ def _persist_and_echo_preceding_notice(
echo_payload["starts_work"] = False
echo_payload["metadata"] = {**metadata}
sender("user_message", echo_payload)
except Exception as exc:
except Exception:
pass
async def _dispatch_completion_user_notice(

View File

@ -145,7 +145,13 @@ def inject_runtime_user_message(
"runtime_guidance": True,
"runtime_guidance_original": raw,
}
for key in ("task_id", "command_id"):
# 关键:不能把子智能体任务 id 透传为顶层 task_id——本事件由主任务 sender
# 发出、走主任务事件流,若顶层 task_id 是子任务 idsender 的 setdefault
# 不会覆盖前端轮询的「任务归属守卫」task_id !== currentTaskId 即丢弃)
# 会把这条通知扔掉;叠加 socket 通道在轮询模式下不渲染,导致运行期 inline
# 完成通知只能刷新后从历史看到2026-08-12 定位,后台指令不受影响是因为
# 它透传的是 command_id 而非 task_id。子任务 id 仍保留在 metadata 中。
for key in ("command_id",):
value = (extra_metadata or {}).get(key)
if value is not None:
payload[key] = value

View File

@ -90,7 +90,7 @@ from .conversation_stats import (
collect_upload_events,
summarize_upload_events,
)
from .deep_compression import run_deep_compression
from .deep_compression import run_deep_compression, heal_stale_compression_flag
conversation_bp = Blueprint('conversation', __name__)
@ -1781,18 +1781,23 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
def get_conversation_compression_status(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try:
normalized_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
data = terminal.context_manager._get_conversation_manager_for_id(normalized_id).load_conversation(normalized_id) or {}
target_manager = terminal.context_manager._get_conversation_manager_for_id(normalized_id)
data = target_manager.load_conversation(normalized_id) or {}
meta = data.get("metadata", {}) or {}
# 与 /api/status 同一套懒清理:进程重启后残留的压缩标记按 pid 判活。
compression_in_progress = heal_stale_compression_flag(
target_manager, normalized_id, meta, context_manager=terminal.context_manager
)
return jsonify({
"success": True,
"data": {
"conversation_id": normalized_id,
"compression_in_progress": bool(meta.get("compression_in_progress", False)),
"compression_mode": meta.get("compression_mode"),
"compression_stage": meta.get("compression_stage"),
"compression_in_progress": compression_in_progress,
"compression_mode": meta.get("compression_mode") if compression_in_progress else None,
"compression_stage": meta.get("compression_stage") if compression_in_progress else None,
"compression_error": meta.get("compression_error"),
"compression_count": int(meta.get("compression_count", 0) or 0),
"compression_job_id": meta.get("compression_job_id"),
"compression_job_id": meta.get("compression_job_id") if compression_in_progress else None,
}
})
except Exception as e:

View File

@ -2,7 +2,9 @@ from __future__ import annotations
import asyncio
import json
import os
from datetime import datetime
from functools import wraps
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@ -47,6 +49,89 @@ def _emit(sender, event_type: str, payload: Dict[str, Any]):
pass
def _clear_compression_state_on_error(func):
"""压缩过程异常退出时清理持久化的 compression_in_progress 标记。
压缩中途异常或调用侧取消若不清标记对话 metadata 会永久残留
in_progress=True前端据此锁输入栏/拦切换对话只能删对话解决
"""
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception:
try:
web_terminal = kwargs.get("web_terminal")
conversation_id = kwargs.get("conversation_id")
cm = getattr(web_terminal, "context_manager", None)
if cm is not None and conversation_id:
if getattr(cm, "current_conversation_id", None) == conversation_id:
cm.set_compression_state(in_progress=False)
else:
target_manager = (
cm._get_conversation_manager_for_id(conversation_id)
if hasattr(cm, "_get_conversation_manager_for_id")
else cm.conversation_manager
)
target_manager.update_conversation_metadata(conversation_id, {
"compression_in_progress": False,
"compression_mode": None,
"compression_stage": None,
"compression_job_id": None,
"compression_resume_payload": None,
"compression_pid": None,
})
except Exception:
pass
raise
return wrapper
def heal_stale_compression_flag(
target_manager,
conversation_id: str,
metadata: Dict[str, Any],
context_manager=None,
) -> bool:
"""读取压缩进行态并懒清理残留标记,返回有效的 in_progress。
compression_in_progress 持久化在对话 metadata 进程在压缩中途被杀时标记
残留为 True压缩随进程死亡不可能仍在进行会导致前端误以为仍在压缩
判定依据标记写入时记录了发起进程的 pidcompression_pid
compression_mixin.set_compression_state与当前进程 pid 不一致或缺失
即旧版本写入的标记即判定为残留并清除磁盘 + 内存双清
"""
if not bool((metadata or {}).get("compression_in_progress", False)):
return False
flag_pid = (metadata or {}).get("compression_pid")
if flag_pid is not None and flag_pid == os.getpid():
return True
clear_updates = {
"compression_in_progress": False,
"compression_mode": None,
"compression_stage": None,
"compression_job_id": None,
"compression_resume_payload": None,
"compression_pid": None,
}
try:
target_manager.update_conversation_metadata(conversation_id, clear_updates)
except Exception:
pass
# 同步内存中的 metadata该对话若正被加载工具循环的
# is_compression_in_progress() 读的是内存副本,不清理会一直误判压缩中。
try:
if (
context_manager is not None
and getattr(context_manager, "current_conversation_id", None) == conversation_id
and isinstance(getattr(context_manager, "conversation_metadata", None), dict)
):
context_manager.conversation_metadata.update(clear_updates)
except Exception:
pass
return False
def _normalize_deep_compression_records(metadata: Dict[str, Any]) -> List[Dict[str, Any]]:
records = metadata.get("deep_compression_records")
if not isinstance(records, list):
@ -378,6 +463,7 @@ def _mark_history_compacted(history: List[Dict[str, Any]], *, round_index: int,
return marked
@_clear_compression_state_on_error
async def run_deep_compression(
*,
web_terminal,

View File

@ -128,13 +128,22 @@ def get_status(terminal, workspace, username):
status['conversation']['created_at'] = current_conv_data.get('created_at')
status['conversation']['updated_at'] = current_conv_data.get('updated_at')
meta = current_conv_data.get("metadata", {}) or {}
# 压缩标记是持久化的,进程在压缩中途被杀会残留 True压缩随进程
# 死亡),这里按 pid 懒清理,避免前端误判压缩中锁死输入栏。
from ..deep_compression import heal_stale_compression_flag
compression_in_progress = heal_stale_compression_flag(
terminal.context_manager._get_conversation_manager_for_id(current_conv),
current_conv,
meta,
context_manager=terminal.context_manager,
)
status['conversation']['compression'] = {
"in_progress": bool(meta.get("compression_in_progress", False)),
"mode": meta.get("compression_mode"),
"stage": meta.get("compression_stage"),
"in_progress": compression_in_progress,
"mode": meta.get("compression_mode") if compression_in_progress else None,
"stage": meta.get("compression_stage") if compression_in_progress else None,
"error": meta.get("compression_error"),
"count": int(meta.get("compression_count", 0) or 0),
"job_id": meta.get("compression_job_id"),
"job_id": meta.get("compression_job_id") if compression_in_progress else None,
}
except Exception as exc:
log_conn_diag(f"status conversation-meta-failed user={username} error={exc}")

View File

@ -269,7 +269,7 @@
:tool-settings="toolSettings"
:tool-settings-loading="toolSettingsLoading"
:settings-open="settingsOpen"
:compressing="compressing || compressionInProgress"
:compressing="compressionActiveForCurrentConversation"
:current-conversation-id="currentConversationId"
:icon-style="iconStyle"
:tool-category-icon="toolCategoryIcon"

View File

@ -227,10 +227,23 @@ export const computed = {
displayModeSwitchDisabled() {
return !!this.policyUiBlocks.block_virtual_monitor;
},
compressionActiveForCurrentConversation() {
// 压缩锁按对话隔离:只有「正在查看的对话就是正在压缩的对话」时才锁;
// 其他对话与 /new 新建页currentConversationId 为空)不受影响。
if (!this.compressionInProgress && !this.compressing) {
return false;
}
const cid = this.compressionConversationId;
if (!cid) {
// 未记录来源(旧数据/异常路径兑底):只锁有打开对话的页面,/new 不锁
return !!this.currentConversationId;
}
return cid === this.currentConversationId;
},
displayLockEngaged() {
// 对话级并行后:运行中的任务不再硬锁输入栏——当前对话需要保持可输入以排队/停止,
// 其他对话需要保持可输入以并行运行。仅压缩期间保持锁定。
return !!this.compressionInProgress || !!this.compressing;
// 其他对话需要保持可输入以并行运行。仅当前对话压缩期间保持锁定。
return this.compressionActiveForCurrentConversation;
},
currentWorkspaceHasRunningTask() {
// 对话级隔离后:仅当「当前对话」有运行中任务时才拦截发送;
@ -258,8 +271,7 @@ export const computed = {
this.taskInProgress ||
monitorLock ||
this.stopRequested ||
this.compressionInProgress ||
this.compressing
this.compressionActiveForCurrentConversation
);
},
/**
@ -288,8 +300,7 @@ export const computed = {
!this.streamingUi &&
!this.stopRequested &&
!(this.monitorIsLocked && this.chatDisplayMode === 'monitor') &&
!this.compressionInProgress &&
!this.compressing
!this.compressionActiveForCurrentConversation
);
},
composerStreamingForInput() {

View File

@ -18,35 +18,8 @@ export const actionMethods = {
}
},
async createNewConversation() {
if (this.compressionInProgress || this.compressing) {
const confirmed = await this.confirmAction({
title: '压缩进行中',
message: '对话正在压缩中,切换对话会导致压缩失败,确认要继续吗?',
confirmText: '确认',
cancelText: '取消'
});
if (!confirmed) {
return;
}
try {
if (this.currentConversationId) {
await fetch(`/api/conversations/${this.currentConversationId}/compression_cancel`, {
method: 'POST'
});
}
} catch (e) {
console.warn('取消压缩失败:', e);
}
this.compressionInProgress = false;
this.compressing = false;
this.compressionMode = '';
this.compressionStage = '';
if (this.compressionToastId) {
this.uiDismissToast(this.compressionToastId);
this.compressionToastId = null;
}
}
// 已移除「压缩中需确认并取消压缩」拦截:多对话独立运行后,某对话压缩中
// 不影响新建/切换其他对话(压缩锁已按对话隔离)。
debugLog('创建新对话...');
traceLog('createNewConversation:start', {
currentConversationId: this.currentConversationId,

View File

@ -184,36 +184,9 @@ export const loadMethods = {
// 不会串到新对话fire-and-forget不阻塞加载
this.flushReasoningEffortSave?.();
if ((this.compressionInProgress || this.compressing) && !force) {
const confirmed = await this.confirmAction({
title: '压缩进行中',
message: '对话正在压缩中,切换对话会导致压缩失败,确认要继续吗?',
confirmText: '确认',
cancelText: '取消'
});
if (!confirmed) {
this.suppressTitleTyping = false;
this.titleReady = true;
return;
}
try {
if (this.currentConversationId) {
await fetch(`/api/conversations/${this.currentConversationId}/compression_cancel`, {
method: 'POST'
});
}
} catch (e) {
console.warn('取消压缩失败:', e);
}
this.compressionInProgress = false;
this.compressing = false;
this.compressionMode = '';
this.compressionStage = '';
if (this.compressionToastId) {
this.uiDismissToast(this.compressionToastId);
this.compressionToastId = null;
}
}
// 已移除「压缩中切换对话需确认/取消压缩」拦截:多对话独立运行后,压缩在
// 后端随对话任务进行,切换视图不影响压缩本身;压缩锁也按对话隔离
// compressionActiveForCurrentConversation不再存在对话级全局锁。
// 注意:加载已有对话时必须保留该对话自身的模型/模式,不能套用用户默认值。

View File

@ -44,6 +44,7 @@ export const chatMethods = {
this.compressing = true;
this.compressionInProgress = true;
this.compressionConversationId = this.currentConversationId;
this.compressionMode = 'manual';
this.compressionStage = 'requesting';
this.compressionError = '';
@ -120,6 +121,7 @@ export const chatMethods = {
}
this.compressing = false;
this.compressionInProgress = false;
this.compressionConversationId = null;
this.compressionMode = '';
this.compressionStage = '';
}

View File

@ -10,7 +10,7 @@ import {
export const sendMethods = {
async handleSendOrStop() {
if (this.compressionInProgress) {
if (this.compressionActiveForCurrentConversation) {
this.uiPushToast({
title: '对话自动压缩中',
message: '当前不可发送/停止,请等待压缩完成',
@ -37,7 +37,7 @@ export const sendMethods = {
// 传统模式waitingForSubAgent=truetaskInProgress=true 。多智能体模式has_running_multi_agent=true。
// 此时新文本消息应直接发送,触发主智能体下一轮工作,而不是被进队列等任务结束。
const mainIdle = typeof this.mainChatIdle === 'function' ? this.mainChatIdle : (
!this.streamingUi && !this.stopRequested && !this.compressionInProgress && !this.compressing
!this.streamingUi && !this.stopRequested && !this.compressionActiveForCurrentConversation
);
if (this.composerBusy && mainIdle && hasText) {
// 如果有 pending 问题(子智能体询问主智能体),仍走问答路径,不走直接发送
@ -115,7 +115,7 @@ export const sendMethods = {
const presetText = typeof options?.presetText === 'string' ? options.presetText : null;
const usePresetText = presetText !== null;
if (this.compressionInProgress) {
if (this.compressionActiveForCurrentConversation) {
this.uiPushToast({
title: '对话自动压缩中',
message: '压缩完成后才能继续发送消息',
@ -473,7 +473,7 @@ export const sendMethods = {
return;
}
this._stopTaskRunning = true;
if (this.compressionInProgress) {
if (this.compressionActiveForCurrentConversation) {
this._stopTaskRunning = false;
this.uiPushToast({
title: '对话自动压缩中',

View File

@ -204,17 +204,24 @@ export const resourceMethods = {
this.conversationHasImages = false;
this.conversationHasVideos = false;
}
// 压缩状态与 has_images/has_videos 同理status 反映的是后端 terminal 当前
// 对话的压缩状态。空对话/显式新建路由(/new上没有打开对话status 里的
// 压缩标记属于残留的旧对话上下文,应用会把输入锁错误地带到 /new 页。
const compression = status?.conversation?.compression;
if (compression && typeof compression === 'object') {
this.compressionInProgress = !!compression.in_progress;
this.compressionMode = compression.mode || '';
this.compressionStage = compression.stage || '';
this.compressionError = compression.error || '';
} else {
this.compressionInProgress = false;
this.compressionMode = '';
this.compressionStage = '';
this.compressionError = '';
if (hasConversation && !onExplicitNewRoute) {
if (compression && typeof compression === 'object') {
this.compressionInProgress = !!compression.in_progress;
this.compressionMode = compression.mode || '';
this.compressionStage = compression.stage || '';
this.compressionError = compression.error || '';
this.compressionConversationId = this.compressionInProgress ? this.currentConversationId : null;
} else {
this.compressionInProgress = false;
this.compressionConversationId = null;
this.compressionMode = '';
this.compressionStage = '';
this.compressionError = '';
}
}
},

View File

@ -25,6 +25,10 @@ export const compressionMethods = {
}
const wasInProgress = !!this.compressionInProgress;
this.compressionInProgress = !!data.in_progress;
// 记录压缩所属对话:压缩锁只作用于该对话,不影响其他对话与 /new 新建页
this.compressionConversationId = this.compressionInProgress
? (data.conversation_id || this.currentConversationId || null)
: null;
this.compressionMode = data.mode || '';
this.compressionStage = data.stage || '';
if (this.compressionInProgress && !wasInProgress) {
@ -68,6 +72,7 @@ export const compressionMethods = {
this.compressionToastId = null;
}
this.compressionInProgress = false;
this.compressionConversationId = null;
this.compressionMode = '';
this.compressionStage = '';
this.compressionError = '';

View File

@ -28,7 +28,7 @@ export const reviewMethods = {
if (this.compressing || this.streamingMessage || !this.isConnected) {
return;
}
if (this.compressionInProgress) {
if (this.compressionActiveForCurrentConversation) {
this.uiPushToast({
title: '对话自动压缩中',
message: '当前对话正在压缩,请稍后再试',

View File

@ -135,6 +135,9 @@ export function dataState() {
// 对话压缩状态
compressing: false,
compressionInProgress: false,
// 正在压缩的对话 id压缩锁只作用于该对话本身不影响其他对话与 /new 新建页
// (多对话独立运行后,压缩不再是对话级全局锁)。
compressionConversationId: null,
compressionMode: '',
compressionStage: '',
compressionError: '',

View File

@ -97,6 +97,9 @@ class CompressionMixin:
"compression_error": error if in_progress else None,
"compression_resume_payload": resume_payload if in_progress else None,
"compression_job_id": job_id if in_progress else None,
# 记录发起压缩的进程 pid进程重启后该标记必然残留压缩随进程死亡
# status 读取侧据此做懒清理(见 deep_compression.heal_stale_compression_flag
"compression_pid": os.getpid() if in_progress else None,
}
for k, v in updates.items():
self.conversation_metadata[k] = v