新增: 无人值守P0任务边界全链
- 任务级授权边界判定与审批旁路单点插桩 - boundary_json 随会话落库(V45 可逆迁移) - 六路非正常终局统一安全停靠与托盘通知 - 每五轮进度事件与会话头部进度条 - 前端边界配置卡侧栏徽标及 UX 校验补齐
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user