新增: 无人值守P0任务边界全链

- 任务级授权边界判定与审批旁路单点插桩
- boundary_json 随会话落库(V45 可逆迁移)
- 六路非正常终局统一安全停靠与托盘通知
- 每五轮进度事件与会话头部进度条
- 前端边界配置卡侧栏徽标及 UX 校验补齐
This commit is contained in:
lxy
2026-10-04 16:27:30 +08:00
parent 8396849d04
commit b2d44f85e9
29 changed files with 2773 additions and 142 deletions
+176 -13
View File
@@ -29,6 +29,8 @@
pub mod agentic;
pub mod augmentation;
pub mod audit;
// 无人值守任务边界(TaskBoundary):结构 + 纯判定函数 + 单测(设计 §二/§三)
pub mod boundary;
pub mod code_intel;
pub mod command_stream;
pub mod commands;
@@ -236,6 +238,9 @@ pub enum AiChatEvent {
/// - `status`:"completed" | "truncated" | "failed"
/// - `rounds`/`tokens`:实际执行轮数与累计 token(启动失败为 0)
/// - `rejected_tools`:被拒工具清单("工具名×次数",如 "write_file×3"),None=本次零拒绝
/// - `stop_kind`:停止类别(snake_case 字符串,为前端备料):"cancelled"(用户停止级联)
/// | "wall_clock" | "max_rounds" | "token_budget" | "empty_convergence" | "error"
/// | "converged"(正常收敛);None=未分类(防御,当前所有路径均有类别)
AiAgentFinished {
agent_id: String,
conversation_id: Option<String>,
@@ -246,6 +251,9 @@ pub enum AiChatEvent {
/// S2 报告协议:子循环运行期被拒工具汇总,让父会话与用户看见子代理被拒了什么。
/// 可选字段:前端 S1 完成卡不消费该字段也不破坏(事件多字段向后兼容),消费留二期。
rejected_tools: Option<Vec<String>>,
/// 停止类别(为前端备料):"cancelled" | "wall_clock" | "max_rounds" | "token_budget"
/// | "empty_convergence" | "error" | "converged"。前端不消费该字段也不破坏。
stop_kind: Option<String>,
},
/// 流式调用自动重试提示流前失败重试中)
///
@@ -425,6 +433,34 @@ pub enum AiChatEvent {
resolved_by: String,
conversation_id: Option<String>,
},
/// 无人值守安全停靠(设计 §四,挂起家族终局):五路非正常终局(审批超时取消/挂起后
/// 取消/熔断/预算耗尽/Fatal)统一出口。语义:现场已保存、generating 已复位(非死等)、
/// reason 即待办清单(停止原因+已拒绝操作提示),前端操作卡据此提供恢复入口
/// (既有 ai_chat_force_send/重发语义)。
///
/// 前端契约(钉死):事件名 `AiTaskParked`,字段
/// `{ conversation_id: string, reason: string, stop_kind?: string }`。
/// stop_kind 复用 AiAgentFinished 词表扩展: "approval_timeout" | "cancelled"
/// | "circuit_breaker" | "stall_breaker" | "max_rounds" | "fatal"。
AiTaskParked {
conversation_id: Option<String>,
reason: String,
stop_kind: Option<String>,
},
/// 无人值守进度可观测(设计 §4.3):主 loop 每 PROGRESS_EVERY_N_ROUNDS 轮 emit 一次,
/// 长任务静默期(子代理长跑/大工具执行/重入退避)前端可判断「在干活还是死了」。
/// 事件即逝不落库,终局(AiCompleted/AiTaskParked 等)不补发。
///
/// 前端契约(钉死):事件名 `AiTaskProgress`,字段
/// `{ conversation_id: string, round: number, max_rounds: number, tokens_used: number }`。
/// round 1 起算(对齐 AiAgentRound 口径);max_rounds=0 表示不限轮次,
/// tokens_used 为本 loop 累计 token。
AiTaskProgress {
conversation_id: Option<String>,
round: u32,
max_rounds: u32,
tokens_used: u32,
},
}
/// 模型信息(AiModelList 事件的载荷,对齐 apps/df-miniapp 模型选择器 ModelInfo 字段)。
@@ -662,19 +698,6 @@ pub struct AiSession {
}
impl AiSession {
pub fn new() -> Self {
Self {
active_provider_id: None,
active_conversation_id: None,
active_conv_created_at: None,
pending_approvals: HashMap::new(),
per_conv: HashMap::new(),
deleted_convs: HashSet::new(),
conv_origins: HashMap::new(),
save_locks: HashMap::new(),
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
}
}
/// 推导指定会话读侧状态视图(只读,不写任何字段)。
///
@@ -738,6 +761,25 @@ impl AiSession {
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
/// 新建顶层会话容器(单例字段全默认:无活跃对话/无挂起审批/per_conv 空)。
///
/// B 批4 后本结构仅剩容器字段(单会话态全部下沉 per_conv),构造极简;
/// 历史单例字段(messages/stop_flag/...)的初值语义由 [`PerConvState::new`] 承接
/// (调用方 session.conv(cid) 惰性创建时使用)。
pub fn new() -> Self {
Self {
active_provider_id: None,
active_conversation_id: None,
active_conv_created_at: None,
pending_approvals: HashMap::new(),
per_conv: HashMap::new(),
deleted_convs: HashSet::new(),
conv_origins: HashMap::new(),
save_locks: HashMap::new(),
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
}
}
}
#[cfg(test)]
@@ -1031,6 +1073,30 @@ pub struct PerConvState {
/// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后
/// 位置漂移,内容不变)。随会话销毁不落库。
pub tool_view_decisions: std::collections::HashMap<u64, bool>,
/// 无人值守任务级授权边界(设计 §二):发起时一次声明,存续期内边界内零审批。
/// None=未声明(现状逐次审批零倒退)。内存真相源在 per_conv,落库随行
/// (ai_conversations.boundary_json,save 写/switch 读还原),随会话同亡。
/// 子代理 per_conv 独立新建恒 None,不受父边界影响(拍板点 6 fail-closed)。
pub boundary: Option<boundary::TaskBoundary>,
/// 只读工具结果缓存的「写后失效」下界(消息索引,纯内存态,不落库)。
///
/// 治「只读结果缓存无写失效」缺陷:find_cached_readonly_result 通过反扫历史 messages
/// 命中旧 tool_result 回填缓存,但历史里 write_file/run_command 等写工具一旦执行过,
/// 其之前的只读结果(read_file/grep/list_directory)已可能过期——写后 LLM 重读会拿到
/// 改前内容,缓存把脏数据喂回 LLM 致其基于旧状态决策。
///
/// 语义:非只读工具(`is_readonly(name)==false`)执行成功回填后,本字段被推到当时
/// `messages.len()`(即该写结果 push 之后的位置)。缓存命中扫描仅认 `idx >= floor`
/// 的历史调用——写在前的只读结果全部失效(过界即脏),写在后的(写后新读)有效。
///
/// 脏失效范围必然覆盖「写工具影响的任何路径」:floor 是全量下界,不区分写的是哪个
/// 文件(精确按路径失效需解析工具语义,过度工程;宁可多失效少喂脏——失效只是
/// 多一次真执行,正确性无损)。
///
/// 索引漂移防御:floor 在锁内与 messages 同步读写;pop_last_assistant_round /
/// clear 会缩短 messages,此时 floor 若 > len 用 `min` 钳制(等于全失效后重新积累,
/// 保守方向安全)。floor 单调不减(取 max),避免写后多轮回退。
pub readonly_cache_floor: usize,
}
/// 单个目标记录(含状态追踪)。
@@ -1041,6 +1107,73 @@ pub struct GoalEntry {
pub status: GoalStatus,
}
#[cfg(test)]
mod tests_task_parked {
use super::*;
/// 停靠事件 serde 契约(前端钉死):事件名 type=AiTaskParked,字段
/// {conversation_id: string, reason: string, stop_kind?: string}。
/// 前端下批消费,本测试锁死后端不悄悄改协议。
#[test]
fn ai_task_parked_event_serde_contract() {
let ev = AiChatEvent::AiTaskParked {
conversation_id: Some("conv-1".to_string()),
reason: "审批超时(15分钟)自动取消,任务已安全停靠".to_string(),
stop_kind: Some("approval_timeout".to_string()),
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["type"], "AiTaskParked", "事件名(前端契约)");
assert_eq!(v["conversation_id"], "conv-1", "conversation_id 序列化为 string");
assert_eq!(v["reason"], "审批超时(15分钟)自动取消,任务已安全停靠");
assert_eq!(v["stop_kind"], "approval_timeout");
// stop_kind 可选语义:None → null(前端 stop_kind? 可选消费)
let ev2 = AiChatEvent::AiTaskParked {
conversation_id: Some("c".to_string()),
reason: "r".to_string(),
stop_kind: None,
};
let v2 = serde_json::to_value(&ev2).unwrap();
assert!(v2["stop_kind"].is_null(), "stop_kind 缺省序列化为 null(可选字段)");
}
/// stop_kind 词表锁定(六路非正常终局):前端操作卡/图标按此分派,新增值须同步前端契约。
#[test]
fn ai_task_parked_stop_kind_vocabulary() {
const KINDS: [&str; 7] = [
"approval_timeout", "cancelled", "circuit_breaker",
"stall_breaker", "max_rounds", "fatal", "midstream",
];
for k in KINDS {
let ev = AiChatEvent::AiTaskParked {
conversation_id: Some("c".to_string()),
reason: "r".to_string(),
stop_kind: Some(k.to_string()),
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["stop_kind"], k);
}
}
/// 进度事件 serde 契约(前端钉死):事件名 type=AiTaskProgress,字段
/// {conversation_id: string, round: number, max_rounds: number, tokens_used: number}。
/// max_rounds=0 语义=不限轮次(前端进度条按无上限处理),数值类型本测试锁死。
#[test]
fn ai_task_progress_event_serde_contract() {
let ev = AiChatEvent::AiTaskProgress {
conversation_id: Some("conv-1".to_string()),
round: 6,
max_rounds: 20,
tokens_used: 12345,
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(v["type"], "AiTaskProgress", "事件名(前端契约)");
assert_eq!(v["conversation_id"], "conv-1", "conversation_id 序列化为 string");
assert_eq!(v["round"], 6, "round 序列化为 number(1 起算)");
assert_eq!(v["max_rounds"], 20, "max_rounds 序列化为 number(0=不限)");
assert_eq!(v["tokens_used"], 12345, "tokens_used 序列化为 number(loop 累计)");
}
}
/// 目标状态。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -1096,6 +1229,8 @@ impl PerConvState {
workflow_dag_summary: None,
frozen_tool_names: None,
tool_view_decisions: std::collections::HashMap::new(),
boundary: None,
readonly_cache_floor: 0,
}
}
}
@@ -1253,3 +1388,31 @@ pub(crate) async fn execute_run_workflow_for_tool(
"note": "工作流已触发(后台异步执行,完成后自动推进任务状态,失败按退回态回滚)。前端 workflow-event 跟踪进度。"
}))
}
// ============================================================
// 无人值守安全停靠出口(设计 §四):五路非正常终局的统一事件+通知单点
// ============================================================
/// 发停靠事件 AiTaskParked + NeedAction 系统通知(托盘场景补位,复用 notify 基建)。
///
/// 调用方职责(本函数不做,保持单一只读语义):调用前完成「保存现场(落库)+
/// 不保持 generating(conv_state 复位)」两步——各终局路径现状已有
/// (teardown_round/stream_relay Fatal/ai_chat_stop),审批超时路径由本批补齐。
/// reason 即待办清单:把停止原因与恢复指引写成人话,前端操作卡直接展示。
pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &str, stop_kind: &str) {
let ev = AiChatEvent::AiTaskParked {
conversation_id: Some(conv_id.to_string()),
reason: reason.to_string(),
stop_kind: Some(stop_kind.to_string()),
};
event_bus::emit_ai_chat_event(app, ev);
// 托盘场景补位:停靠=需用户回来决策(继续/归档),与审批挂起同用 NeedAction 分组
// (主窗聚焦跳过 + 5s 节流,重复终局不会刷屏)。
notify::notify_session_event(
app,
notify::NotifyKind::NeedAction,
conv_id,
"任务已停靠",
reason,
);
}