新增: 无人值守安全系列(H1-H3) + P5 审计日志与部署运维 + #14 异步委托

- H3 外部干预感知: run_agentic_loop 增 h3_watch_dir 参数, loop 启动取 git HEAD 基线, 每 5 轮探测, HEAD 被外部改写(切分支/reset/他话提交)即 emit_task_parked(stop_kind=external_change) + teardown 停靠; 4 调用方(发送/编辑/强制发送/审批续跑)从 allowed_dirs.persistent 排序取首目录, 修复初版 current_dir 恒空缺陷
- P5 审计日志: df-im-gateway audit.rs append-only SQLite(tunnel_audit 表, payload 4KB 截断, recent/by_request 查询), main.rs 8 打点点位(inbound/command/outbound), 写失败不阻断主链路, 17 tests passed
- P5 部署运维: publish-im-gateway.ps1(musl 交叉编译→scp .new→systemd 原子切换→9191 验证→自动回滚) + systemd unit + nginx im.1216.top 反代 + 运维手册
- #14 异步委托: 受理/完成两段式 + 桌面任务卡(后台进行中徽章/轮次/切换) + 飞书双端同构, 关联缺陷 #8-#12 速清

验收: cargo check workspace EXIT 0, cargo test workspace 全绿(711+560+33+12...), vue-tsc EXIT 0
This commit is contained in:
lxy
2026-10-06 15:59:10 +08:00
parent 7c252cff96
commit 438d47bc9f
17 changed files with 987 additions and 12 deletions
@@ -0,0 +1,22 @@
[Unit]
Description=DevFlow IM Gateway (飞书/企微/钉钉/Web 四端远程接入)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/df-im-gateway
ExecStart=/opt/df-im-gateway/df-im-gateway
Restart=on-failure
RestartSec=3
# ── 运行时 env(与本地 config.rs 对齐;token 与 u-relay 同源)──
Environment=GATEWAY_RELAY_URL=ws://127.0.0.1:9180/ws/commander
Environment=GATEWAY_RELAY_TOKEN=devflow-relay-default-token
Environment=GATEWAY_DEFAULT_CHANNEL=chan-work
Environment=GATEWAY_DATA_DIR=/opt/df-im-gateway/data
Environment=RUST_LOG=info,df_im_gateway=debug
StandardOutput=append:/opt/df-im-gateway/gateway.log
StandardError=append:/opt/df-im-gateway/gateway.err.log
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,23 @@
# df-im-gateway nginx 反代片段(与 u-relay 同机,复用 *.1216.top 通配符证书)
# 放入 /etc/nginx/conf.d/ 后 nginx -t && systemctl reload nginx
# Web Chat 页面 + WS 同端口 9191(网关 axum 直接托管)
server {
listen 443 ssl;
server_name im.1216.top;
ssl_certificate /etc/nginx/sslkey/_.1216.top.pem;
ssl_certificate_key /etc/nginx/sslkey/_.1216.top.key;
location / {
proxy_pass http://127.0.0.1:9191;
proxy_http_version 1.1;
# WS 升级头(与 u-relay 反代同款)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 流式响应不缓冲(SSE/流式文本)
proxy_buffering off;
proxy_read_timeout 300s;
}
}
@@ -0,0 +1,86 @@
# df-im-gateway 运维手册
> 四端 IM 网关(飞书/企微/钉钉/Web)远程接入 DevFlow AI Chat。
> 部署拓扑:与 u-relay 同机(39.99.243.191),WS Commander 经 u-relay 连桌面端 df-tunnel。
## 一、拓扑与环境
```
飞书/企微/钉钉/Web ──► df-im-gateway(9191, 同机)
│ ws://127.0.0.1:9180/ws/commander (Commander 身份)
▼
u-relay(9180, nginx 前置 wss://u-relay.1216.top)
│ /ws/agent
▼
桌面端 DevFlow(df-tunnel Agent)
```
| 项 | 值 |
|---|---|
| 服务器 | 39.99.243.191:22 (root 免密, Ubuntu 22.04) |
| 部署路径 | /opt/df-im-gateway/(二进制 + data/ + 日志) |
| 监听端口 | 9191(本机明文,nginx 前置 im.1216.top → wss) |
| systemd | df-im-gateway.service(Restart=on-failure, 开机自启) |
| token | devflow-relay-default-token(与 u-relay/桌面端三端同源) |
| 数据库 | /opt/df-im-gateway/data/{sessions.db, audit.db}(SQLite WAL) |
## 二、日常运维
```bash
# 状态 / 日志 / 重启
systemctl status df-im-gateway
journalctl -u df-im-gateway -n 50 --no-pager
systemctl restart df-im-gateway
# 端口验证
ss -tlnp | grep 9191
# 审计查询(最近 20 条)
sqlite3 /opt/df-im-gateway/data/audit.db \
"SELECT datetime(ts/1000,'unixepoch','+8 hours'), direction, action, chat_id, outcome FROM tunnel_audit ORDER BY id DESC LIMIT 20;"
# 会话映射表
sqlite3 /opt/df-im-gateway/data/sessions.db "SELECT * FROM session_map;"
```
⚠️ systemd 托管,勿 kill Main PID、勿 nohup 起野进程;重启一律 `systemctl restart`。
## 三、发布流程
```powershell
# 本机(Windows)一键发布:单测 → musl 交叉编译 → scp → 原子切换 → 9191 验证 → 失败自动回滚
powershell -File E:\wk-lab\devflow\crates\df-im-gateway\scripts\publish-im-gateway.ps1
# 仅构建不部署: 加 -SkipDeploy;仅部署不构建: 加 -SkipBuild
```
首次部署需手动铺底:
```bash
scp crates/df-im-gateway/deploy/df-im-gateway.service root@39.99.243.191:/etc/systemd/system/
ssh root@39.99.243.191 'systemctl daemon-reload && systemctl enable df-im-gateway'
# nginx(可选,Web 端公网访问时): scp deploy/nginx-im.1216.top.conf → /etc/nginx/conf.d/ && nginx -t && systemctl reload nginx
```
## 四、回滚
```bash
cd /opt/df-im-gateway
systemctl stop df-im-gateway
LATEST=$(ls -t df-im-gateway.bak.* | head -1)
cp "$LATEST" df-im-gateway && chmod +x df-im-gateway
systemctl start df-im-gateway
```
## 五、配置变更
| env | 说明 | 改法 |
|---|---|---|
| GATEWAY_RELAY_TOKEN | 与 u-relay 三端同源,**改必须同步桌面端** | 改 unit Environment= → daemon-reload → restart |
| GATEWAY_DEFAULT_CHANNEL | 默认路由 channel | 同上 |
| GATEWAY_WHITELIST | 用户白名单(空=任何人可用,生产必设!) | 同上 |
| FEISHU_APP_ID/SECRET | 飞书长连接 | 同上 |
## 六、已知项
- token 仍为 MVP 单共享值(per-user 是 S2 计划)
- 日志 gateway.log 追加无轮转,可配 logrotate 或定期 truncate
- 四端 E2E 联调依赖各平台凭证到位(飞书/钉钉/企微)
@@ -0,0 +1,70 @@
# df-im-gateway 一键发布脚本(复用 publish-u-relay 模式)
# 用法: powershell -File scripts\publish-im-gateway.ps1 [-SkipBuild] [-SkipDeploy]
# 流程: cargo test → musl 交叉编译 → scp 上传(.new 中转) → systemd 原子切换 → 9191 验证
param(
[switch]$SkipBuild,
[switch]$SkipDeploy
)
$ErrorActionPreference = "Stop"
$Repo = "E:/wk-lab/devflow"
$Target = "x86_64-unknown-linux-musl"
$Server = "root@39.99.243.191"
$RemoteDir = "/opt/df-im-gateway"
$ServiceName = "df-im-gateway"
$LocalBin = "$Repo/target-musl/$Target/release/df-im-gateway"
function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan }
if (-not $SkipBuild) {
Step "Step 0: 单测基线(df-im-gateway)"
cargo test -p df-im-gateway --manifest-path "$Repo/Cargo.toml"
if ($LASTEXITCODE -ne 0) { throw "单测失败,中止发布" }
Step "Step 1: musl 交叉编译(rust-lld self-contained)"
$env:CARGO_TARGET_DIR = "$Repo/target-musl"
cargo build --release -p df-im-gateway --manifest-path "$Repo/Cargo.toml" --target $Target
if ($LASTEXITCODE -ne 0) { throw "编译失败,中止发布" }
Remove-Item Env:\CARGO_TARGET_DIR -ErrorAction SilentlyContinue
if (-not (Test-Path $LocalBin)) { throw "产物不存在: $LocalBin" }
$size = [math]::Round((Get-Item $LocalBin).Length / 1MB, 1)
Write-Host "产物: $LocalBin (${size}MB)"
}
if ($SkipDeploy) { Write-Host "`nSkipDeploy: 仅构建完成"; exit 0 }
Step "Step 2: 服务器预检 + 备份"
ssh $Server "mkdir -p $RemoteDir && test -f $RemoteDir/df-im-gateway && cp $RemoteDir/df-im-gateway $RemoteDir/df-im-gateway.bak.`$(date +%Y%m%d%H%M%S) || echo '首次部署,无备份'"
Step "Step 3: 上传(.new 中转防 Text file busy)"
scp $LocalBin "${Server}:$RemoteDir/df-im-gateway.new"
if ($LASTEXITCODE -ne 0) { throw "scp 失败" }
Step "Step 4: 原子切换 + systemd 重启"
ssh $Server "bash -s" @'
set -e
cd /opt/df-im-gateway
systemctl stop df-im-gateway 2>/dev/null || true
mv -f df-im-gateway.new df-im-gateway
chmod +x df-im-gateway
systemctl start df-im-gateway
sleep 1
systemctl is-active df-im-gateway
'@
if ($LASTEXITCODE -ne 0) { throw "切换/启动失败,检查 journalctl -u df-im-gateway"; }
Step "Step 5: 健康验证"
ssh $Server "ss -tlnp | grep 9191 && journalctl -u df-im-gateway -n 5 --no-pager | sed 's/\x1b\[[0-9;]*m//g'"
if ($LASTEXITCODE -ne 0) {
Write-Host "❌ 9191 未监听,执行回滚..." -ForegroundColor Red
ssh $Server "bash -s" @'
set -e
cd /opt/df-im-gateway
systemctl stop df-im-gateway
LATEST=$(ls -t df-im-gateway.bak.* 2>/dev/null | head -1)
if [ -n "$LATEST" ]; then cp "$LATEST" df-im-gateway; chmod +x df-im-gateway; systemctl start df-im-gateway; echo "回滚完成: $LATEST"; else echo "无备份可回滚(首次部署失败,保持停止)"; fi
'@
throw "健康检查失败,已回滚"
}
Write-Host "`n✅ df-im-gateway 发布完成 (9191)" -ForegroundColor Green
+236
View File
@@ -0,0 +1,236 @@
//! 审计日志(P5):IM 消息 ↔ TunnelCommand 全量记录(SQLite + WAL,与 SessionStore 同机库文件)
//!
//! 设计口径:
//! - **append-only**:只写不改,无 UPDATE/DELETE——审计的公信力来自不可篡改。
//! - **双向打点**:inbound(IM 用户 → 网关)与 outbound(网关/桌面端 → IM)各记一行,
//! 同一 request_id 关联请求与响应(排障时按 id 串链)。
//! - **失败不阻断**:审计写入失败仅 tracing::warn,绝不影响主链路(审计是增益非闸门)。
//! - 表:tunnel_audit(id 自增 / ts 毫秒 / direction / platform / chat_id / user_id /
//! conv_id / action / request_id / payload 截断 4KB / outcome ok|error|denied)。
//! payload 截断防长消息撑爆库;敏感凭证不经过此层(config 层已隔离)。
//!
//! 查询接口(运维用):recent 按时间倒序限量;by_request 串同请求双向链。
use std::path::Path;
use std::sync::Arc;
use rusqlite::Connection;
const MAX_PAYLOAD: usize = 4096;
/// 审计方向
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// IM 用户 → 网关(入站消息/审批点击/新会话)
Inbound,
/// 网关/桌面端 → IM(流式输出/审批卡片/通知)
Outbound,
/// 网关 → 桌面端(TunnelCommand 下发)
Command,
}
impl Direction {
fn as_str(self) -> &'static str {
match self {
Direction::Inbound => "inbound",
Direction::Outbound => "outbound",
Direction::Command => "command",
}
}
}
#[derive(Clone)]
pub struct AuditLog {
conn: Arc<std::sync::Mutex<Connection>>,
}
/// 打点参数(字段可选,None 写空串,保持列 NOT NULL 简化查询)
pub struct AuditEntry {
pub direction: Direction,
pub platform: String,
pub chat_id: String,
pub user_id: String,
pub conv_id: String,
/// 动作名:message/new_session/approval_click/approval_card/stream/completed/notice/send_cmd/approve_cmd
pub action: String,
/// 关联 ID:入站↔出站串链用(消息场景可用 chat+ts 派生,审批场景用 approval_id)
pub request_id: String,
pub payload: String,
/// ok / error / denied(白名单拒/无 channel)
pub outcome: String,
}
impl AuditLog {
/// 打开(或创建)审计库。默认与 sessions.db 同目录独立文件(隔离写放大)。
pub fn open(path: &Path) -> anyhow::Result<Self> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tunnel_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
direction TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL DEFAULT '',
conv_id TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
request_id TEXT NOT NULL DEFAULT '',
payload TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok'
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON tunnel_audit(ts);
CREATE INDEX IF NOT EXISTS idx_audit_request ON tunnel_audit(request_id);",
)?;
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
}
/// 内存库(测试)
#[allow(dead_code)]
pub fn open_in_memory() -> anyhow::Result<Self> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS tunnel_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL,
direction TEXT NOT NULL, platform TEXT NOT NULL DEFAULT '',
chat_id TEXT NOT NULL DEFAULT '', user_id TEXT NOT NULL DEFAULT '',
conv_id TEXT NOT NULL DEFAULT '', action TEXT NOT NULL,
request_id TEXT NOT NULL DEFAULT '', payload TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT 'ok');",
)?;
Ok(Self { conn: Arc::new(std::sync::Mutex::new(conn)) })
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// 追加一条审计(失败仅告警不冒泡)
pub fn record(&self, e: AuditEntry) {
let payload = if e.payload.len() > MAX_PAYLOAD {
format!("{}…[截断]", &e.payload[..MAX_PAYLOAD])
} else {
e.payload
};
let result = (|| -> anyhow::Result<()> {
let conn = self.conn.lock().map_err(|err| anyhow::anyhow!("audit 锁中毒: {err}"))?;
conn.execute(
"INSERT INTO tunnel_audit (ts, direction, platform, chat_id, user_id, conv_id, action, request_id, payload, outcome) \
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
rusqlite::params![
Self::now_ms(), e.direction.as_str(), e.platform, e.chat_id,
e.user_id, e.conv_id, e.action, e.request_id, payload, e.outcome,
],
)?;
Ok(())
})();
if let Err(err) = result {
tracing::warn!("[audit] 写入失败(不影响主链路): {err}");
}
}
/// 最近 N 条(时间倒序,运维排障)
#[allow(dead_code)]
pub fn recent(&self, limit: usize) -> anyhow::Result<Vec<String>> {
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("audit 锁中毒: {e}"))?;
let mut stmt = conn.prepare(
"SELECT ts, direction, platform, chat_id, user_id, conv_id, action, request_id, outcome \
FROM tunnel_audit ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt.query_map([limit as i64], |row| {
Ok(format!(
"{} [{}] {} plat={} chat={} user={} conv={} req={} outcome={}",
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(6)?,
row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?,
row.get::<_, String>(5)?, row.get::<_, String>(7)?, row.get::<_, String>(8)?,
))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// 按 request_id 串双向链(入站→命令→出站全链)
#[allow(dead_code)]
pub fn by_request(&self, request_id: &str) -> anyhow::Result<Vec<String>> {
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("audit 锁中毒: {e}"))?;
let mut stmt = conn.prepare(
"SELECT ts, direction, action, outcome FROM tunnel_audit WHERE request_id = ?1 ORDER BY id ASC",
)?;
let rows = stmt.query_map([request_id], |row| {
Ok(format!(
"{} [{}] {} outcome={}",
row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?,
))
})?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(direction: Direction, action: &str, req: &str, outcome: &str) -> AuditEntry {
AuditEntry {
direction,
platform: "feishu".into(),
chat_id: "oc_123".into(),
user_id: "u_1".into(),
conv_id: "feishu:oc_123".into(),
action: action.into(),
request_id: req.into(),
payload: "hello".into(),
outcome: outcome.into(),
}
}
#[test]
fn record_and_recent_roundtrip() {
let log = AuditLog::open_in_memory().unwrap();
log.record(entry(Direction::Inbound, "message", "req-1", "ok"));
log.record(entry(Direction::Command, "send_cmd", "req-1", "ok"));
log.record(entry(Direction::Outbound, "stream", "req-1", "ok"));
log.record(entry(Direction::Inbound, "message", "req-2", "denied"));
let rows = log.recent(10).unwrap();
assert_eq!(rows.len(), 4);
// 倒序:最新在前
assert!(rows[0].contains("req-2"));
assert!(rows[0].contains("denied"));
let chain = log.by_request("req-1").unwrap();
assert_eq!(chain.len(), 3);
}
#[test]
fn payload_truncated() {
let log = AuditLog::open_in_memory().unwrap();
let mut e = entry(Direction::Inbound, "message", "req-x", "ok");
e.payload = "x".repeat(10_000);
log.record(e);
let conn_count = |log: &AuditLog| -> i64 {
let conn = log.conn.lock().unwrap();
conn.query_row("SELECT COUNT(*) FROM tunnel_audit", [], |r| r.get(0)).unwrap()
};
assert_eq!(conn_count(&log), 1);
let conn = log.conn.lock().unwrap();
let stored: String = conn
.query_row("SELECT payload FROM tunnel_audit", [], |r| r.get(0))
.unwrap();
assert!(stored.len() < 5_000);
assert!(stored.ends_with("…[截断]"));
}
#[test]
fn record_never_panics_on_poisoned_lock() {
let log = AuditLog::open_in_memory().unwrap();
// 毒锁:持锁 panic 使 Mutex poisoned,record 应 warn 而非 panic
let guard = log.conn.lock().unwrap();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
std::panic::panic_any("poison");
}))
.err();
drop(guard);
log.record(entry(Direction::Inbound, "message", "req-p", "ok")); // 不 panic 即通过
}
}
+75
View File
@@ -9,6 +9,7 @@
//! ``` //! ```
mod adapters; mod adapters;
mod audit;
mod commander; mod commander;
mod config; mod config;
mod feishu; mod feishu;
@@ -43,6 +44,8 @@ async fn main() -> anyhow::Result<()> {
.unwrap_or_else(|_| PathBuf::from("data")); .unwrap_or_else(|_| PathBuf::from("data"));
std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(&data_dir)?;
let store = SessionStore::open(&data_dir.join("sessions.db"))?; let store = SessionStore::open(&data_dir.join("sessions.db"))?;
// 审计日志(P5):IM 消息 ↔ TunnelCommand 全量记录,写失败不阻断主链路
let audit = audit::AuditLog::open(&data_dir.join("audit.db"))?;
info_banner(&cfg); info_banner(&cfg);
@@ -153,11 +156,25 @@ async fn main() -> anyhow::Result<()> {
let tail = throttle.finish(&conv_id); let tail = throttle.finish(&conv_id);
let full = tail.unwrap_or_default(); let full = tail.unwrap_or_default();
let idx = platform_of(&conv_id); let idx = platform_of(&conv_id);
audit.record(audit::AuditEntry {
direction: audit::Direction::Outbound,
platform: String::new(), chat_id: conv_id.clone(),
user_id: String::new(), conv_id: conv_id.clone(),
action: "completed".into(), request_id: String::new(),
payload: full.clone(), outcome: "ok".into(),
});
let _ = adapters[idx] let _ = adapters[idx]
.dispatch(OutboundAction::StreamText { chat_id: conv_id, text: full, done: true }) .dispatch(OutboundAction::StreamText { chat_id: conv_id, text: full, done: true })
.await; .await;
} }
df_tunnel::TunnelEvent::Approval { conv_id, approval_id, description } => { df_tunnel::TunnelEvent::Approval { conv_id, approval_id, description } => {
audit.record(audit::AuditEntry {
direction: audit::Direction::Outbound,
platform: String::new(), chat_id: conv_id.clone(),
user_id: String::new(), conv_id: conv_id.clone(),
action: "approval_card".into(), request_id: approval_id.clone(),
payload: description.clone(), outcome: "ok".into(),
});
let idx = platform_of(&conv_id); let idx = platform_of(&conv_id);
let _ = adapters[idx] let _ = adapters[idx]
.dispatch(OutboundAction::ApprovalCard { .dispatch(OutboundAction::ApprovalCard {
@@ -182,12 +199,26 @@ async fn main() -> anyhow::Result<()> {
// 回复适配器:按来源平台回路由 // 回复适配器:按来源平台回路由
let reply_idx = platform_of(&platform); let reply_idx = platform_of(&platform);
if !cfg_loop.whitelist.is_empty() && !cfg_loop.whitelist.contains(&user_id) { if !cfg_loop.whitelist.is_empty() && !cfg_loop.whitelist.contains(&user_id) {
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: String::new(),
action: "message".into(), request_id: String::new(),
payload: text.clone(), outcome: "denied".into(),
});
let _ = adapters[reply_idx] let _ = adapters[reply_idx]
.dispatch(OutboundAction::Notice { chat_id, text: "⛔ 你不在白名单内".into() }) .dispatch(OutboundAction::Notice { chat_id, text: "⛔ 你不在白名单内".into() })
.await; .await;
continue; continue;
} }
let Some(channel_id) = cfg_loop.route(&text) else { let Some(channel_id) = cfg_loop.route(&text) else {
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: String::new(),
action: "message".into(), request_id: String::new(),
payload: text.clone(), outcome: "error".into(),
});
let _ = adapters[reply_idx] let _ = adapters[reply_idx]
.dispatch(OutboundAction::Notice { chat_id, text: "⚠️ 无可用 channel".into() }) .dispatch(OutboundAction::Notice { chat_id, text: "⚠️ 无可用 channel".into() })
.await; .await;
@@ -202,14 +233,45 @@ async fn main() -> anyhow::Result<()> {
// 走查 #3:新建会话时清理流式残留(上轮异常中止无 // 走查 #3:新建会话时清理流式残留(上轮异常中止无
// Completed 事件,streams entry 永驻会错覆写旧消息) // Completed 事件,streams entry 永驻会错覆写旧消息)
adapters[reply_idx].reset_stream(&chat_id).await; adapters[reply_idx].reset_stream(&chat_id).await;
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: conv.clone(),
action: "new_session".into(), request_id: String::new(),
payload: String::new(), outcome: "ok".into(),
});
conv conv
} }
}; };
// 入站消息审计:request_id 用 conv+毫秒派生,出站 Completed 事件无法
// 回带,此处以 (direction=inbound,message) 为链头,command/outbound
// 侧用同一 conv_id 关联(recent 按时间倒序即还原会话时间线)。
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: conv_id.clone(),
action: "message".into(), request_id: String::new(),
payload: text.clone(), outcome: "ok".into(),
});
audit.record(audit::AuditEntry {
direction: audit::Direction::Command,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: user_id.clone(), conv_id: conv_id.clone(),
action: "send_cmd".into(), request_id: String::new(),
payload: text.clone(), outcome: "ok".into(),
});
let _ = cmd_tx let _ = cmd_tx
.send(df_tunnel::TunnelCommand::Send { conv_id, content: text }) .send(df_tunnel::TunnelCommand::Send { conv_id, content: text })
.await; .await;
} }
InboundEvent::NewSession { platform, chat_id } => { InboundEvent::NewSession { platform, chat_id } => {
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: String::new(), conv_id: String::new(),
action: "new_session_reset".into(), request_id: String::new(),
payload: String::new(), outcome: "ok".into(),
});
let _ = store.remove(&platform, &chat_id); let _ = store.remove(&platform, &chat_id);
let reply_idx = platform_of(&platform); let reply_idx = platform_of(&platform);
let _ = adapters[reply_idx] let _ = adapters[reply_idx]
@@ -227,6 +289,19 @@ async fn main() -> anyhow::Result<()> {
.get_conv(&platform, &chat_id) .get_conv(&platform, &chat_id)
.map(|m| m.conv_id) .map(|m| m.conv_id)
.unwrap_or_else(|| format!("{platform}:{chat_id}")); .unwrap_or_else(|| format!("{platform}:{chat_id}"));
audit.record(audit::AuditEntry {
direction: audit::Direction::Inbound,
platform: platform.clone(), chat_id: chat_id.clone(),
user_id: String::new(), conv_id: conv_id.clone(),
action: "approval_click".into(), request_id: approval_id.clone(),
payload: if approved { "approved".into() } else { "rejected".into() },
outcome: "ok".into(),
}); audit.record(audit::AuditEntry {
direction: audit::Direction::Command,
platform, chat_id, user_id: String::new(), conv_id: conv_id.clone(),
action: "approve_cmd".into(), request_id: approval_id.clone(), payload: if approved { "approved".into() } else { "rejected".into() },
outcome: "ok".into(),
});
let _ = cmd_tx let _ = cmd_tx
.send(df_tunnel::TunnelCommand::Approve { conv_id, approval_id, accepted: approved }) .send(df_tunnel::TunnelCommand::Approve { conv_id, approval_id, accepted: approved })
.await; .await;
@@ -0,0 +1,112 @@
# 上下文根治设计(2026-10-06)—— 治 223:1 结构性浪费
> 铁证:84cb104c 会话 2.2 天烧 4.3 亿 tokens,prompt:completion=223:1,日均 1.9 亿。
> 本质:每轮全量重发历史——轮数线性,token 爆炸,首 token 延迟同步恶化。不是 bug,是组装策略必然。
> 原则:分层组装取代全量重发;历史不删只降级(压缩可逆);对用户透明,对 agent 工具透明。
## 一、分层上下文(治"全量")
- **L1 常驻(固定小)**:system_prompt + pinned_goals + 任务状态卡。目标 <8K,永不随轮次增长。
- **L2 工作集(滑动窗口)**:最近 N 轮原文 + 当前任务活跃文件/结果。按"信息增益"滑动,非固定条数。
- **L3 检索层(按需召回)**:窗口外历史不进 prompt,转为可检索——关键词/语义命中才临时注入(复用 knowledge_inject 的检索管线,扩到对话历史)。
- 无破坏性保证:所有降级内容原文全在库(L3 可召回+压缩可逆 C-r1),agent 能力不降级,丢的是"每轮都带全部家当"的浪费。
## 二、工具结果瘦身(治最大头,先行)
9386 条消息中 tool 4379 条——单条最大 8K+,是上下文膨胀主力。
- 超阈值(如 2K)的工具结果:正文落盘(外置文件/独立表),上下文只留「头部 200 字摘要 + 句柄路径」。
- 与 D2a(过期外置)同构一套机制,两处受益。
- 句柄可被 agent 主动展开(read 句柄文件)——需要时找得回,不需要时不占位。
## 三、压缩升级(从"水位兜底"到"常规操作")
- 现状:120K 水位才触发,1M 窗口下形同虚设。
- 改:压缩不再是水位兜底,而是 L2→L3 的常规滑动——每轮组装时,窗口外的轮次自动结构化摘要(记忆卡形态,信息增益判据保留),原文留库可按段重载(C-r1)。
## 四、量化预期
- prompt 均值 71K → 目标 ≤20K(工作集+召回),单轮成本/延迟降 70%+
- 223:1 → 目标 ≤50:1(继续随 C-m 记忆卡质量优化)
- 长会话不再有"轮数税"——206 轮与 6 轮的单轮成本趋同。
## 五、实施序(每阶段独立可交付,无破坏)
- P1(S):工具结果瘦身(阈值落盘+句柄)——最大头先行,零行为变化
- P2(M):L1/L2/L3 组装器重构(build_for_request 改造,窗口+检索)
- P3(M):压缩常规化+记忆卡产出(C-m1)+按段重载(C-r1)
- P4(S):量化验收(223:1→≤50:1 看板)+回归(agent 能力不降级测试集)
## 六、与旧 C 批次关系
本设计整合取代 C1-C6/C-m1-m3 的发散条目:C-r0(验证后台化)独立不变;C-m2(判据挂 pinned_goals)/C-r1(可逆)并入 P2/P3。旧条目销账以本档为准。
## 七、修订(2026-10-06 用户定调):从「分层组装」升级为「上下文调度系统」
> 定调:同会话连续工作是**常态**,设计目标不是控制会话长度,而是支撑无限长会话的资源调度——OS 虚拟内存同构:窗口有限,会话地址空间无限。
> 修正:撤回"百轮会话收敛拆分"建议;一~六节的 L1/L2/L3 保留为**页管理基建**,本节新增调度层。
### 调度系统设计(新增)
- **页式单位**:上下文按页管理——轮次页/工具结果页/文件页。页=换入换出与压缩的最小单位。
- **热度调度**:页带热度(最近访问/被引用频次)。热页驻工作集(L2);冷页压缩归档(L3,结构化摘要+原文留库);**压缩不丢**=冷页永远可召回。
- **缺页召回**:agent 引用历史(关键词/句柄/goal 关联)→检索命中→临时换入本轮上下文,用完即出——召回不计入常驻。
- **任务聚类切换**(切换能力的核心):工作集按**任务/主题聚类**重组,非纯时间窗口——用户在多任务间切换时,上下文随任务维度切换(当前任务的热页+该任务关联页),并行任务互不污染。任务边界=页的聚类标签(pinned_goals/主题标记)。
- **调度目标函数**:token 花在刀刃=按"任务相关性×信息增益"分配窗口配额;调度决策零 LLM 调用(规则引擎,成本为零)。
### 修订后的一句话
**长会话=常态运行环境;上下文调度器=它的虚拟内存管理器;压缩可召回=它的 swap;任务聚类切换=它的进程调度。**
## 八、再修订(2026-10-06 用户定调):跨会话调度与召回也是常态
> 定调:调度地址空间=会话内 + **跨会话**。会话 A 的产出/结论,会话 B 工作中可召回引用——跨会话不是边界,是同一个页池的不同命名空间。
### 跨会话调度设计(新增)
- **页命名空间分级**:会话内页(默认私有) / **跨会话共享页**(结论页/知识页/任务产出页,显式或按策略晋升共享)。
- **跨会话召回**:检索半径覆盖全页池——会话 B 缺页召回时,共享页与会话 A 的可召回页同池命中,带来源标注(「来自会话 X 的结论」)。
- **与 knowledge 库关系**:知识条目=最高等级的共享页(已晋升、已蒸馏);本设计的跨会话召回把"未蒸馏的对话产出"也纳入召回半径——knowledge 是终点,页池是全程。
- **晋升机制**:会话内页→(蒸馏/用户标记/高频跨任务引用)→共享页→(结构化)→knowledge 条目。晋升是单向门,防未蒸馏噪音污染共享池。
### 多人场景(预留位,下阶段设计)
- 页带 owner 与 ACL 位;共享池分个人池/团队池;多人可召回引用=团队池+权限过滤。
- 本期只预留页结构字段(owner/acl/scope),不做多人实现——接口先行,机制后置。
## 九、完整审视(2026-10-06 二次评审)——四个真问题与修正
### 问题1:机制并存未收敛(最重)
压缩机制(现有)/D2a 工具结果外置/L3 检索召回/knowledge 条目/页池——**五个名字,一套事**(都是"内容降级+可找回")。三次定调追加未合并同类项。
**修正**:统一收敛——**唯一存储=页池,唯一调度器,唯一召回入口**;压缩/D2a/knowledge 都是页池上的策略与晋升形态,不再作为平行系统各自演化。旧机制改造为页池前身,不新建平行通路。
### 问题2:实施范围失控风险
页池+晋升+跨会话+多人——北极星画完了,但一个桌面 app 一次吃下=过度设计。
**修正:v1 范围锁死**=单会话页池+极简调度器(recency+引用计数热度,goal 标签聚类,关键词缺页召回)+knowledge 照旧(它已覆盖跨会话召回大部分需求)。跨会话命名空间/晋升/多人=**v2+,触发条件=单会话版实测后仍有跨会话痛点**。基建等真实触发。
### 问题3:缺评估设计与调度可观测
"agent 能力不降级"怎么证?P4 的回归测试集不存在。
**修正**:①建任务回归集(固定任务清单,改造前后跑对比:完成质量/轮数/成本)②调度器可观测(页命中/召回率/换出量进日志与面板)③误调度代价显性化(热页误换出→下轮回查次数)。
### 问题4:与模型平台原生能力对齐未检查
模型侧可能原生提供上下文编辑/记忆工具(如 Claude 的 context editing/memory tool)——自研调度若与平台能力重复,应优先用平台原生(维护成本低、演化跟随厂商)。
**修正**:实施前先做平台能力对齐检查(一次调研任务),能用原生的不自研;自研部分聚焦平台没有的(任务聚类切换/跨会话页池)。
### 审视后的 v1 锁定范围(修正结论)
P1 工具结果瘦身 + P2 单会话页池与极简调度 + P3 压缩常规化(页池前身改造) + P4 评估集与量化。
七/八节的调度系统与跨会话设计降级为**北极星方向**;实施以本节 v1 范围为准。
## 十、知识引用注入的统筹与分工(2026-10-06 补充:第九节收敛的正向展开)
> 上下文生态的注入类型盘点:工具结果句柄/对话历史页/压缩摘要与记忆卡/knowledge 条目/pinned_goals/技能注入/规范注入——统筹不当=五套并行的重演。
### 分工矩阵(按蒸馏程度×生命周期×注入方式)
| 类型 | 蒸馏程度 | 生命周期 | 注入方式 | 角色 |
|---|---|---|---|---|
| 工具结果句柄 | 原始 | 任务内 | agent 主动 read 展开 | 工作证据 |
| 对话历史页 | 轻摘要 | 会话内→跨会话(v2) | 检索召回临时换入 | 过程记忆 |
| 压缩摘要/记忆卡 | 中蒸馏 | 会话长期 | 工作集常驻(摘要形态) | 阶段结论 |
| knowledge 条目 | 高蒸馏 | 永久 | system_prompt 检索注入 | 沉淀规范 |
| pinned_goals/任务态 | 实时 | 任务期 | L1 常驻 | 当前意图 |
### 统筹四原则
1. **单一来源**:每种内容只有一个当前形态的家(原始在页池/蒸馏在 knowledge),晋升单向门,禁双存双写。
2. **注入方式随蒸馏程度**:越原始越被动(agent 主动拉/句柄展开),越蒸馏越主动(系统注入 prompt)——检索系统替 agent 省的每一分注意力,都来自上游的蒸馏质量。
3. **召回入口统一**:全部类型注册到同一检索面,agent 视角只有一个记忆系统(分类型返回,来源标注)——agent 不需要知道背后几套存储。
4. **三层配额防炸弹**:prompt 注入配额(既有 ≤20 条/1.5K)+每轮召回页配额(新增,如 ≤5 页)+句柄展开按需——总量有闸,信息炸弹在结构上不可能。
### 与既有机制对账
knowledge_inject(检索注入)=统筹第 4 行的既有实现,扩为统一检索面的注册方之一;compress(压缩)=第 3 行的产生器;D2a/P1(句柄)=第 1 行的产生器;审计 subscriber(H4)=统筹的观测面。**无新系统,只有分工明确化。**
+55 -2
View File
@@ -1057,9 +1057,10 @@ graph TD
> 附:挂机汇报自报 🔴10 已修验证通过+43 文件产出——挂机模式推进有效,路径修复别误伤它。 > 附:挂机汇报自报 🔴10 已修验证通过+43 文件产出——挂机模式推进有效,路径修复别误伤它。
## 无人值守可靠性专项(2026-10-06 swarm 立项,设计档:docs/02-架构设计/无人值守可靠性专项设计-20261006.md+temp/V49断点续跑设计-20261006.md) ## 无人值守可靠性专项(2026-10-06 swarm 立项,设计档:docs/02-架构设计/无人值守可靠性专项设计-20261006.md+temp/V49断点续跑设计-20261006.md)
- [x] B1(S):H1 汇报 git 实测附录 ✅(2026-10-06:deliver_completion_report_if_waited 单点 append_git_facts——branch/HEAD/未提交计数三行,非 git 仓静默跳过,超长报告不追,git.rs 增 git_exec 只读薄封装)+ 探活告警口径修正 ✅(同日 18 批:inflight 计数点前移到 handle_message 白名单通过处,覆盖占位卡 HTTP+per-chat 锁排队窗口,旧计数点删除防双计)- [ ] B2(M):H2 token 三闸(抄 sub_agent round_budget_stop)+ H4 产出审计 subscriber - [x] B1(S):H1 汇报 git 实测附录 ✅(2026-10-06:deliver_completion_report_if_waited 单点 append_git_facts——branch/HEAD/未提交计数三行,非 git 仓静默跳过,超长报告不追,git.rs 增 git_exec 只读薄封装)+ 探活告警口径修正 ✅(同日 18 批:inflight 计数点前移到 handle_message 白名单通过处,覆盖占位卡 HTTP+per-chat 锁排队窗口,旧计数点删除防双计)- [ ] B2(M):H2 token 三闸(抄 sub_agent round_budget_stop)+ ~~H4 产出审计 subscriber~~ ✅H4 已落地(2026-10-06 勘察后实施:lib.rs setup 订阅 ai_event_bus,AiToolCallStarted{id,name,args,conversation_id}入内存配对表(上限512防泄漏)/AiToolCallCompleted{id,result,conversation_id}配对取 name 落兜底审计行(decided_by=event_bus,risk 保守 Low)——Completed 无 name/isError 字段,失败态由主链 audit_tool_call 口径区分;事件契约核验为主链 emit 双写全覆盖,与 process_tool_calls 审计重复面已评估=同 call_id 幂等可容忍;async 块只持 db:Arc<Database> 不持 AppState 整体(内含非 Send tunnel 回调致 E0277,已修))。**H4 剩余价值点=无人值守产出/失败统计订阅,正确实施时机=W-d1 挂机循环落地时**(有真实消费方,现在上 subscriber 属过度设计)——故 H4 降级为观察项登记,本批实施内容为兜底审计 subscriber(B2 销账只认 H2)
- [x] B3(M):V49 防覆盖定稿+单测 ✅(本会话前批:#11 update_full 剥离 loop_state 列,df-storage conversation 8 passed)+ ai_list_recoverable IPC ✅(2026-10-06:conversation.rs parked 专项完备真相清单 id/title/model/updated_at,不受侧栏 limit 截断,lib.rs 已注册)+ 桌面「可恢复」入口 ✅(2026-10-06 本批:TopBar recoverableTasks 双轨数据源——conversations loop_state 实时投影+ai_list_recoverable IPC 完备真相合并去重,面板展开触发 IPC 拉取,失败退回投影;ai.ts listRecoverable 封装)。验收:cargo check EXIT 0 + vue-tsc EXIT 0。 - [x] B3(M):V49 防覆盖定稿+单测 ✅(本会话前批:#11 update_full 剥离 loop_state 列,df-storage conversation 8 passed)+ ai_list_recoverable IPC ✅(2026-10-06:conversation.rs parked 专项完备真相清单 id/title/model/updated_at,不受侧栏 limit 截断,lib.rs 已注册)+ 桌面「可恢复」入口 ✅(2026-10-06 本批:TopBar recoverableTasks 双轨数据源——conversations loop_state 实时投影+ai_list_recoverable IPC 完备真相合并去重,面板展开触发 IPC 拉取,失败退回投影;ai.ts listRecoverable 封装)。验收:cargo check EXIT 0 + vue-tsc EXIT 0。
- [ ] B4(L):ai_recover_parked 重启恢复 + H3 外部干预感知(git HEAD 比对→Parked) - [x] B2(M):H2 token 三闸 ✅(2026-10-06 实施+走查重构:agentic/mod.rs 轮间双闸——UNATTENDED_WALL_SECS=30min 挂钟闸+UNATTENDED_MAX_TOKENS=5M token 闸,超限 emit_task_parked(reason=wall_clock/token_budget)+teardown_round 正典收尾安全停靠;走查批收敛两分支 40 行复制粘贴为单 over 判定块+类型统一 u64;轮次闸由 effective_max 既有上界承担不重复)+ ~~H4 产出审计 subscriber~~ ✅H4 已落地(2026-10-06 勘察后实施:lib.rs setup 订阅 ai_event_bus,AiToolCallStarted{id,name,conversation_id}入内存配对表(上限512防泄漏)/AiToolCallCompleted{id,result,conversation_id}配对取 name 落兜底审计行(decided_by=event_bus,risk 保守 Low)——Completed 无 name/isError 字段,失败态由主链 audit_tool_call 口径区分;与 process_tool_calls 审计重复面=同 call_id 幂等可容忍;async 块只持 db:Arc<Database> 不持 AppState 整体(非 Send tunnel 回调致 E0277,已修);走查批删废话注释+命名规范化 v/etype→event/event_type)。**H4 剩余价值点=无人值守产出/失败统计订阅,正确实施时机=W-d1 挂机循环落地时**。验收:cargo check EXIT 0 + cargo test --lib 711 passed
- [x] B4(M):ai_recover_parked 重启恢复 ✅(2026-10-06 实施,断点续跑闭环收尾:①后端 conversation.rs ai_recover_parked IPC——守卫两道(会话存在且 loop_state='parked' 非则拒/conv_states.can_accept_request 双保险)→注入恢复引导消息「回顾上下文继续未竟任务,不足以判断先向用户确认」→复用 spawn_agentic_session 正典链路零新协议(loop 启动即置 running,parked 自然清除),lib.rs 已注册 ②前端 TopBar 可恢复面板行改双钮布局(切回|恢复琥珀描边钮),recoverParked 防重入 recoveringIds Set→成功后 switchToRunning 切回,api.ts recoverParked 封装,i18n zh/en recoverNow 两键)。剩 H3 外部干预感知(git HEAD 比对→Parked)独立小批。验收:cargo check EXIT 0 + vue-tsc EXIT 0 + cargo test --lib 711 passed
## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行) ## UI/UX 体验设计实施项(2026-10-06 设计档:temp/异步任务会话UIUX设计-20261006.md,依赖 13/14/16/17 机制先行)
- [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令) - [ ] U1 任务卡生命周期组件(双端):桌面 TaskCard 八态投影枚举(全部由既有事件派生,零引擎新状态)+飞书 header 状态色结构卡(JSON 元素级规格已给,动作 v1 走文本命令)
@@ -1068,3 +1069,55 @@ graph TD
- [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持 - [ ] U4 纠偏交互:桌面直发默认注入·下一轮生效(排队可切),回执+前缀逃生;飞书回复即注入保持
- [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策 - [ ] U5 异常态话术公式:状态+真实原因+已保留什么+≥2条出路;禁自动判死静默任务(15min 重入退避是合法等待),给判断材料不替用户决策
- [ ] U6 跨端一致性:引擎唯一写者,双端不直同步,"实时"=秒级最终一致;断线恢复桌面走 DB 回放/飞书走 /status 回查 - [ ] U6 跨端一致性:引擎唯一写者,双端不直同步,"实时"=秒级最终一致;断线恢复桌面走 DB 回放/飞书走 /status 回查
20. [ ] **委托制伪受理断层**(13:38 实证):受理卡在引擎确认可启动之前发出——12:57 任务停滞 Generating 时,「进度」spawn 被引擎拒但受理卡已发,之后零通知零进度(比 09:51 立即报错更隐蔽)。修:受理前过 can_accept_request,不可启动→直接回「前任务在跑,已排队/稍后」;可启动→才发受理卡+watcher 接力。附带:委托制下 inflight 生命周期改跟任务走(handle_message 提前返回致 18 修正再次失效,13:18 误报复现)。验收:同场景复测,占用期发消息得到明确排队提示而非静默受理。
> **符号语义规范(2026-10-06 用户反馈:受理卡「✅ 已受理」歧义,随 19 统一出口一并落地)**:
> - 🚀 受理/开工(任务开始才用,禁 ✅)|⏳ 进行中/排队/等待|✅ 仅限完成且成功|❌ 失败(必须附原因)|⚠️ 警告/需注意(附数据)|📎 附录/附加信息
> - 原则:符号=状态的一眼判据,同一符号跨场景语义必须一致;受理卡改为「🚀 已受理,后台执行中」;完成推送才允许 ✅ 开头。
## 飞书交互面全量盘查(2026-10-06,11 条缺陷按恼人度降序,修复前话术避免引用 /status)
31. [ ] 🔴 **过程推送散乱刷屏**(14:06 用户截图反馈「信息太散乱爆炸了」):push_to_chat(:797)每调用必新开独立卡片,通道无聚合语义——昨日调试把链路测试/修复上线/加固明细等 L3 过程逐条直推 4+ 条进群,与 U3 分级相悖。修双层:①机制=push_to_chat 增滚动汇总卡——同 chat 维护在途事务卡 message_id(内存即可,重启另起),进度用已有 update_card(:730)原地追加;首推需 send_markdown_card 返回新卡 id(现 :683 is_ok() 丢弃,比照 reply_markdown_card);文本以 ✅/❌ 开头=终态,更新后清句柄下条另起新卡;更新失败回退新发并重绑(比照 deliver_result 兜底)。②纪律=L3 过程默认不推群,落日志+进度文件;push 收敛「需决策/完成✅」两时机,调试确需的验证推也进汇总卡不新开。验收:同事务多步进度群时间线只占 1 张卡,过程类推送零直达。
21. [ ] 🔴 **/status 被话术推荐但不存在**(chat.rs:387/:486 两处):用户发了被当任务烧一轮 LLM。修:实现 /status(读 conv 状态返回进度摘要,配合委托制价值高)或先改话术删除引用。验收:发 /status 得进度卡而非 spawn。
22. [ ] 🔴 **35min 兜底回查会投出上一任务旧回答冒充本次结果**(feishu_channel.rs:483/:939):回查整 conv 最后 assistant 文本无任务边界。修:回查限定受理时间戳之后的 assistant,无则给真实状态话术。
23. [ ] 🔴 **watcher 首超时后永不弃守**(:486 continue 与注释"二次超时弃守"相反)+错误路径只发 AiError 无 AiAgentFinished→每 35min 重刷过期文案+任务泄漏。修:二次超时真弃守+AiError 也广播 Finished(status=error)。
24. [ ] **无取消/审批通路**:跑偏无法叫停(飞书零 stop 通道);审批挂起对飞书用户不可见(流式卡冻结无解释)。修:/cancel 对接 ai_stop_loop;审批事件上卡片(「需审批:xxx,回复 批准/拒绝」)。
25. [ ] **非文本消息静默丢**(:243):发图/文件零反馈。修:回「暂仅支持文本,媒体能力规划中」(D7 前的兜底)。
26. [ ] **⏳话术承诺排队实际不入队**——话术与行为不符。修:改话术为实况(「可稍后重发」)或实现真排队(与 20 受理断层同修)。
27. [ ] **快任务兜底死分支**(:449 elapsed>=3s 入场即查恒假,「秒级任务直投终版」不可达)。修:受理卡发送后计时或事件驱动判定。
28. [ ] **注入末轮竞态**:loop 已在末轮则补充指令无人消费静默丢失。修:注入后检查 loop 剩余轮次,末轮则自动续一轮。
29. [ ] **push_to_chat 取 KV map 首条**(:792):多 chat 绑定可能发错会话。修:按 conv 反查 chat。
30. [ ] 细节批::389 反引号不闭合;/new 大小写与附言容错;/new 时旧任务在跑无提示;标点半全角混排。
## 上下文根治专项(2026-10-06 设计定稿:docs/02-架构设计/上下文根治设计-20261006.md,取代旧 C 发散条目)
- [ ] P1(S):工具结果瘦身——超 2K 结果正文落盘+句柄,上下文留摘要+路径(与 D2a 同构,最大头先行)
- [ ] P2(M):L1/L2/L3 分层组装器(build_for_request 改造:常驻+滑动窗口+检索召回)
- [ ] P3(M):压缩常规化(水位兜底→常规滑动)+记忆卡 C-m1+按段重载 C-r1
- [ ] P4(S):量化验收——223:1→≤50:1,prompt 均值 71K→≤20K,agent 能力不降级回归
- 注:C-r0(验证后台化)独立不变;C-m2/C-r1 并入 P2/P3;旧 C 发散条目以新设计档为准销账。
### 上下文根治补充项(2026-10-06 完整审视第十节产出,设计档 docs/02-架构设计/上下文根治设计-20261006.md 第九/十节)
- [ ] G7 平台能力对齐调研(P2 前置):模型/平台原生上下文能力盘点(Claude context editing/memory tool 等)vs 自研边界——能用原生不自研;自研聚焦平台没有的(任务聚类切换/跨会话页池)。产出:对齐结论一页,定 P2 增删。
- [ ] P2 细化:统一检索面——knowledge_inject 扩为全类型注册方(工具句柄/历史页/记忆卡/knowledge 同一召回入口,分类型返回+来源标注)+三层配额(prompt ≤20 条/每轮召回 ≤5 页/句柄按需)+调度器指标(页命中率/召回率/换出量)进日志与面板。
- [ ] P4 细化:任务回归集建设——固定任务清单(5-10 个代表任务),组装器改造前后跑对比(完成质量/轮数/token 成本),作为"能力不降级"的可证口径。
### B5 无人值守续作引擎(2026-10-06 用户实证"总停下确定性任务"的根治,挂机治理核心缺口)
- [ ] **B5a 挂钟闸分任务定级**:确定性任务(看板任务/用户拍板项)挂钟闸放宽(如 120min)或按预估时长配置;闸到点改「检查点+续跑」(存进度不停工),仅非确定性/失控疑似才硬停靠。
- [ ] **B5b 停靠自动续作**:停靠/parked/批收敛后自动回读看板——有 parked 可续任务→自动 resume;有未完批次→自动领单续作;真需决策才停(附决策项)。无人值守的"永动"机制,CLAUDE.md 领单纪律的运行时化。
- [ ] **B5c 审批无人值守策略**:挂机中审批拦截按用户预设策略处置(低风险自动批+留痕,高风险停下+通知),策略 yml 可配。验收:挂机 4h 连续推进模拟(确定性批次队列),零人工干预零停顿;超大任务挂钟闸到点走检查点续跑。
### 任务自主推进全链卡点-治法映射(2026-10-06 补全,B5 系总览)
链路:感知领单→开工检查→执行→停靠/收敛→恢复→续作→汇报
- 感知领单:CLAUDE.md 领单纪律(新会话 ✔)/**挂机长会话无回读驱动→B5b 治**
- 执行中:30min 挂钟闸硬停→**B5a 治**;审批拦截无策略→**B5c 治**;**工具失败低效重试(404 连环十几次,无退避换路策略,同 yideng 教训)→新增 B5d:工具失败快速放弃+换路径+退避策略**;长上下文每轮变慢(223:1)→上下文根治 P1-P4 治;外部干预 loop 认知过时→B4(H3)治
- 停靠/收敛:停后不自动续→**B5b 治**;parked 恢复需人工点→B5b 自动恢复治
- 可见性:进度/卡点/审批对用户不可见→13 过程流式+24 审批卡治
- 汇报:B1-H1 git 实测已治 ✔
- 远期(真实需求触发):多任务并行度/跨会话任务分发
**补齐优先序:B5d 工具重试策略 → B5a 挂钟定级 → B5b 自动续作 → B5c 审批策略 → B2 预算闸 → 13 过程流式**
### 13a 飞书过程可见性加固(2026-10-06 用户实证"卡点到结束才回复",13 的最小件提前独立做,零新机制)
- [ ] 13a-1 刷新可观测:refresher 的 update_card 失败从"静默跳过"改为 feishu_log 记录+连续失败计数(≥3 连败告警)——当前刷了还是没刷、刷败全不可知,排查无从下手。
- [ ] 13a-2 静默期心跳汇报:AiHeartbeat(20s 既有事件)接入 refresher——超 N 分钟无内容刷新→主动刷一行「仍在跑:第 X 轮 · 最近动作 yyy」(数据源 AiAgentRound/最后工具名),防静默期卡片冻结。
- [ ] 13a-3 阶段汇报:AiTaskProgress(每 5 轮,mod.rs:1119 已 emit)接入 refresher——长任务的阶段性进展对飞书用户可见(当前只有桌面消费)。
- [ ] 13a-4 受理→首刷衔接:受理卡到首个 delta/心跳之间的静默窗口检查(>60s 无首刷则心跳兜底)。
验收:206 轮级长任务期间,占位卡至少每 3-5 分钟有一次真实更新(心跳或阶段),失败可见于 feishu-channel.log。
31. [ ] **AI-Ops→devflow 派工通道**(多 bot 编排正统通道,2026-10-06 缺口实证):AI-Ops 无法直接触发 devflow 开工(飞书 bot 消息不互触发/WebChat 无/remote_bridge 走 tunnel 不外用)。方案二选一:①u-claw 侧新增 ask_devflow(与既有 ask_uclaw 对等,经 remote_bridge 协议下发启动指令) ②devflow 侧看板轮询引擎(定时扫 todo.md 新🔴/高优条目自动开工,CLAUDE.md 领单纪律的运行时化=B5b 同源)。建议 ② 与 B5b 合并实现(同一套自动续作引擎)。验收:AI-Ops 写看板后 15min 内 devflow 自主开工,零人工中转。
+9 -1
View File
@@ -194,8 +194,16 @@ pub async fn try_continue_agent_loop(
session.conv(&conv_id_owned).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1 session.conv(&conv_id_owned).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1
}; };
// H3 外部干预感知:取首个持久授权目录(排序取首保证稳定)作为被监视工作区。
let h3_watch_dir = {
let dirs = state.allowed_dirs.read().await;
let mut sorted: Vec<_> = dirs.persistent.iter().cloned().collect();
sorted.sort();
sorted.first().map(|p| p.to_string_lossy().to_string())
};
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await; super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch, h3_watch_dir).await;
}); });
} }
+96
View File
@@ -98,6 +98,16 @@ pub const PROGRESS_EVERY_N_ROUNDS: usize = 5;
pub const ROUND_REENTRY_MAX: usize = 3; pub const ROUND_REENTRY_MAX: usize = 3;
pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900]; pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900];
/// H2 token 预算三闸(无人值守可靠性专项 B2,抄 sub_agent round_budget_stop 语义):
/// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出
/// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断):
/// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock;
/// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget;
/// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。
/// 超限走既有 emit_task_parked——前端词表已预留,零新契约。
pub const UNATTENDED_WALL_SECS: u64 = 30 * 60;
pub const UNATTENDED_MAX_TOKENS: u64 = 5_000_000;
/// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs /// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs
/// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。 /// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。
/// ///
@@ -439,6 +449,9 @@ pub(crate) async fn run_agentic_loop(
// F1 并发 epoch(2026-08-05):调用方 spawn 前 `per_conv.loop_epoch.fetch_add(1)` 后传入, // F1 并发 epoch(2026-08-05):调用方 spawn 前 `per_conv.loop_epoch.fetch_add(1)` 后传入,
// loop 捕获自己的 epoch。退出 / Guard 复位 / emit 只认 owner(epoch 匹配),否则是旧 loop。 // loop 捕获自己的 epoch。退出 / Guard 复位 / emit 只认 owner(epoch 匹配),否则是旧 loop。
loop_epoch: u64, loop_epoch: u64,
// H3 外部干预感知:被监视的工作区目录(调用方从 allowed_dirs.persistent 取,None=无授权
// 目录,探测静默跳过)。传目录而非 HEAD:loop 启动时自行取基线,续跑循环自动重置基线。
h3_watch_dir: Option<String>,
) { ) {
// F1 并发 epoch + F2 存活心跳:一次锁内取 per_conv 关键 Arc + 建 guard。 // F1 并发 epoch + F2 存活心跳:一次锁内取 per_conv 关键 Arc + 建 guard。
// guard.new 同步(transition 经 ConvStateStore 无 await),可在此锁内调。 // guard.new 同步(transition 经 ConvStateStore 无 await),可在此锁内调。
@@ -1102,6 +1115,22 @@ pub(crate) async fn run_agentic_loop(
// 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发) // 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发)
let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations }; let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations };
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)
let loop_started = tokio::time::Instant::now();
// H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。
// 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop
// 运行期改写了工作区 HEAD(切分支/commit/reset),当前任务的工作前提已失效,继续跑有破坏面。
// 无监视目录/非 git 仓/命令失败=无法感知,静默跳过(探测是增益非阻断)。
let h3_baseline_head = if let Some(dir) = h3_watch_dir.as_deref() {
let (out, ok) = crate::commands::ai::tools::git::git_exec(
"rev-parse", &["HEAD"], dir,
).await;
if ok { out.trim().to_string() } else { String::new() }
} else {
String::new()
};
// panic 防护:loop 主体(for + 达上限收尾 + 正常完成收尾)整体包 catch_unwind。 // panic 防护:loop 主体(for + 达上限收尾 + 正常完成收尾)整体包 catch_unwind。
// 背景:本函数由 IPC 层 tauri::async_runtime::spawn 托管,主体 panic 时任务静默死亡, // 背景:本函数由 IPC 层 tauri::async_runtime::spawn 托管,主体 panic 时任务静默死亡,
// conv 卡 Generating(无终态事件,前端无法恢复)。捕获后经 unwind_to_result 把 panic 转 // conv 卡 Generating(无终态事件,前端无法恢复)。捕获后经 unwind_to_result 把 panic 转
@@ -1113,6 +1142,73 @@ pub(crate) async fn run_agentic_loop(
// 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。 // 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。
let outcome = unwind_to_result(async { let outcome = unwind_to_result(async {
for iteration in start_iteration..effective_max { for iteration in start_iteration..effective_max {
// ── H2 三闸(B2):轮间粒度检查,超限安全停靠(轮内不中断,下一轮头生效) ──
{
let elapsed = loop_started.elapsed().as_secs();
let total_tokens = tokens.total() as u64;
let over = if elapsed >= UNATTENDED_WALL_SECS {
Some((
"wall_clock",
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", UNATTENDED_WALL_SECS / 60),
))
} else if total_tokens >= UNATTENDED_MAX_TOKENS {
Some((
"token_budget",
format!("任务累计消耗达 {} tokens 上限,安全停靠:防无人值守预算失控,点继续或重发消息可追加授权续跑", UNATTENDED_MAX_TOKENS),
))
} else {
None
};
if let Some((reason, message)) = over { tracing::warn!(conv_id = %conv_id, elapsed, total_tokens, "[ai] H2 闸触发({reason}),任务安全停靠");
crate::commands::ai::emit_task_parked(&app_handle, &conv_id, &message, reason);
// 走 teardown 正典收尾(save + guard 复位 + AiCompleted(incomplete))再退
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
false, &provider_config, &llm_concurrency, &mut guard,
&emit_usage, last_round_estimated,
Some(true), None, Some(reason), true,
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
).await;
return;
}
// H3 外部干预感知(B4):每 5 轮探测一次 git HEAD(轮间粒度,非 git 仓跳过)。
// HEAD≠基线=有人动了工作区(切分支/reset/外部 commit),任务工作前提失效,
// 安全停靠交还用户决策。轻量 rev-parse ~10ms,5 轮一次可忽略。
if !h3_baseline_head.is_empty()
&& iteration % PROGRESS_EVERY_N_ROUNDS == 0
{
let head_now = crate::commands::ai::tools::git::git_exec(
"rev-parse", &["HEAD"], h3_watch_dir.as_deref().unwrap_or("."),
).await;
if head_now.1 && head_now.0.trim() != h3_baseline_head {
tracing::warn!(
conv_id = %conv_id,
baseline = %h3_baseline_head,
current = %head_now.0.trim(),
"[ai] H3 检测到 git HEAD 被外部改写,任务安全停靠"
);
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id,
"检测到代码仓库 HEAD 在任务运行期间被外部修改(可能是切分支/回滚/其他会话提交),任务工作前提已变化,已安全停靠:请确认仓库状态后决定继续或重新安排任务",
"external_change",
); let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
false, &provider_config, &llm_concurrency, &mut guard,
&emit_usage, last_round_estimated,
Some(true), None, Some("external_change"), true,
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
).await;
return;
}
}
}
// ── 进度可观测段(设计 §4.3):每 N 轮 emit AiTaskProgress ── // ── 进度可观测段(设计 §4.3):每 N 轮 emit AiTaskProgress ──
// round 1 起算对齐 AiAgentRound 口径;max_rounds=0=不限轮次(前端按无上限处理)。 // round 1 起算对齐 AiAgentRound 口径;max_rounds=0=不限轮次(前端按无上限处理)。
// owner 守卫:被接管的旧 loop 不发(防串会话进度)。事件即逝不落库,终局不补发。 // owner 守卫:被接管的旧 loop 不发(防串会话进度)。事件即逝不落库,终局不补发。
+33 -3
View File
@@ -446,9 +446,16 @@ pub async fn ai_regenerate(
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1 session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1
}; };
// H3 外部干预感知:取首个持久授权目录(排序取首稳定)作被监视工作区。
let h3_watch_dir = {
let dirs = state.allowed_dirs.read().await;
let mut sorted: Vec<_> = dirs.persistent.iter().cloned().collect();
sorted.sort();
sorted.first().map(|p| p.to_string_lossy().to_string())
};
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch, h3_watch_dir).await;
}); });
Ok("ok".to_string()) Ok("ok".to_string())
@@ -894,9 +901,16 @@ pub(crate) async fn spawn_agentic_session(
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1 session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1
}; };
// H3 外部干预感知:取首个持久授权目录(排序取首稳定)作被监视工作区。
let h3_watch_dir = {
let dirs = state.allowed_dirs.read().await;
let mut sorted: Vec<_> = dirs.persistent.iter().cloned().collect();
sorted.sort();
sorted.first().map(|p| p.to_string_lossy().to_string())
};
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch, h3_watch_dir).await;
}); });
// 返回 conv_id(共用核心:IPC 层映射回 "ok",编排节点消费 conv_id 等待完成通知) // 返回 conv_id(共用核心:IPC 层映射回 "ok",编排节点消费 conv_id 等待完成通知)
@@ -2055,6 +2069,13 @@ pub async fn ai_chat_edit(
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1 session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1
}; };
// H3 外部干预感知:取首个持久授权目录(排序取首稳定)作被监视工作区。
let h3_watch_dir = {
let dirs = state.allowed_dirs.read().await;
let mut sorted: Vec<_> = dirs.persistent.iter().cloned().collect();
sorted.sort();
sorted.first().map(|p| p.to_string_lossy().to_string())
};
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop( run_agentic_loop(
@@ -2073,6 +2094,7 @@ pub async fn ai_chat_edit(
0, 0,
model_override, model_override,
loop_epoch, loop_epoch,
h3_watch_dir,
) )
.await; .await;
}); });
@@ -2324,8 +2346,16 @@ pub async fn ai_chat_force_send(
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
let max_retries = state.agent_max_retries.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
// H3 外部干预感知:取首个持久授权目录(排序取首稳定)作被监视工作区。
let h3_watch_dir = {
let dirs = state.allowed_dirs.read().await;
let mut sorted: Vec<_> = dirs.persistent.iter().cloned().collect();
sorted.sort();
sorted.first().map(|p| p.to_string_lossy().to_string())
};
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch, h3_watch_dir).await;
}); });
Ok("ok".to_string()) Ok("ok".to_string())
@@ -302,6 +302,55 @@ pub async fn ai_list_recoverable(
.collect()) .collect())
} }
/// B4(无人值守可靠性专项):恢复 parked 会话(断点续跑闭环收尾动作)。
///
/// V49 闭环最后一块:B3 已提供 parked 清单(可恢复入口),本 IPC 接恢复动作——
/// 复用 spawn_agentic_session 正典链路(provider 解析/占用检查/loop_epoch/spawn),
/// 注入恢复引导消息让 LLM 自查上下文后续跑;loop 启动即置 loop_state='running'
/// (既有逻辑),parked 态自然清除。恢复前守卫:
/// ① 会话须存在且 loop_state='parked'(非 parked 拒绝,防误触跑会话);
/// ② conv_states.can_accept_request(目标 conv 真活跃则拒绝,双保险)。
#[tauri::command]
pub async fn ai_recover_parked(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
) -> Result<String, String> {
use super::chat::spawn_agentic_session;
let record = state
.ai_conversations
.get_by_id(&conversation_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
if record.loop_state.as_deref() != Some("parked") {
return Err(format!(
"会话 {} 非可恢复状态(loop_state={:?}),仅 parked 会话可恢复",
conversation_id, record.loop_state
));
}
if !state.conv_states.can_accept_request(&conversation_id) {
return Err("该会话正在生成中,无需恢复".to_string());
}
// 恢复引导消息:LLM 上下文里既有对话历史完整(消息拆分存储回放),只需一句指令触发续跑。
let message = "[系统恢复] 应用重启导致上次任务中断,已完成进度已保留。请回顾上方对话上下文,继续完成未竟的任务;若上下文不足以判断进度,先向用户确认已完成的步骤再继续。"
.to_string();
spawn_agentic_session(
&app,
&state,
message,
None, // language:沿用会话既有 agent_language
None, // skill:恢复不挂技能
None, // model_override:沿用会话既有模型
Some(conversation_id.clone()),
None, // parts:纯文本
None, // mention_spans
true, // pin_active:恢复动作即用户当前意图
)
.await
}
/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端) /// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端)
#[tauri::command] #[tauri::command]
pub async fn ai_conversation_switch( pub async fn ai_conversation_switch(
+52
View File
@@ -268,6 +268,57 @@ pub fn run() {
}, },
); );
// H4 兜底审计 subscriber(无人值守可靠性专项 B2):订阅 ai_event_bus,
// Started{id,name,conversation_id}→入配对表 / Completed{id,result,conversation_id}→
// 经配对表取 name 落审计行。兜「事件到达但主链未落审计行」的旁路场景,
// 统一记 completed(Completed 无 name/isError,失败态由主链口径区分)。
{
let audit_db = state.db.clone();
let mut audit_rx = state.ai_event_bus.subscribe();
tauri::async_runtime::spawn(async move {
// call_id → (name, conversation_id) 配对表(容量上限防泄漏)
let mut pending: std::collections::HashMap<String, (String, Option<String>)> =
std::collections::HashMap::new();
while let Ok(event) = audit_rx.recv().await {
let Some(event_type) = event.get("type").and_then(|t| t.as_str()).map(|s| s.to_string()) else { continue };
let Some(call_id) = event.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()) else { continue };
match event_type.as_str() {
"AiToolCallStarted" => {
let name = event.get("name").and_then(|n| n.as_str()).unwrap_or("unknown").to_string();
let conv = event.get("conversation_id").and_then(|c| c.as_str()).map(|s| s.to_string());
if pending.len() > 512 {
pending.clear(); // 防泄漏:异常堆积时整体丢弃(审计兜底可容忍)
}
pending.insert(call_id, (name, conv));
}
"AiToolCallCompleted" => {
let Some((name, conv)) = pending.remove(&call_id) else { continue };
let Some(conv_id) = conv.as_deref() else { continue };
let repo = df_storage::crud::AiToolExecutionRepo::new(&audit_db);
let result_str = match event.get("result") {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(other) => Some(other.to_string()),
None => None,
};
crate::commands::ai::audit::record_audit(
&repo,
conv_id,
&call_id,
&name,
"{}", // 事件面无原始 args(arguments 参数非 Option,空 JSON 占位)
"completed",
df_ai::ai_tools::RiskLevel::Low, // subscriber 侧无元数据,保守 Low(主链已记真实级别)
result_str,
Some("event_bus"),
None,
).await;
}
_ => {}
}
}
});
}
// connect(失败非阻断,supervisor 兜底重连)。收发循环在 client 内部 spawn。 // connect(失败非阻断,supervisor 兜底重连)。收发循环在 client 内部 spawn。
// 注:on_command 用 clone 传入 connect(原值留给 supervisor 重连复用,Arc clone 廉价)。 // 注:on_command 用 clone 传入 connect(原值留给 supervisor 重连复用,Arc clone 廉价)。
match state match state
@@ -450,6 +501,7 @@ pub fn run() {
commands::ai::ai_conversation_create, commands::ai::ai_conversation_create,
commands::ai::ai_conversation_list, commands::ai::ai_conversation_list,
commands::ai::ai_list_recoverable, commands::ai::ai_list_recoverable,
commands::ai::ai_recover_parked,
commands::ai::ai_conversation_switch, commands::ai::ai_conversation_switch,
// S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态) // S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态)
commands::ai::ai_conversation_peek, commands::ai::ai_conversation_peek,
+5
View File
@@ -332,6 +332,11 @@ export const aiApi = {
return invoke('ai_list_recoverable') return invoke('ai_list_recoverable')
}, },
/** B4 恢复 parked 会话(断点续跑闭环:注入恢复引导消息,重走 spawn 正典链路) */
recoverParked(conversationId: string): Promise<string> {
return invoke('ai_recover_parked', { conversationId })
},
/** 切换到指定对话 */ /** 切换到指定对话 */
switchConversation(conversationId: string): Promise<AiConversationDetail> { switchConversation(conversationId: string): Promise<AiConversationDetail> {
return invoke('ai_conversation_switch', { conversationId }) return invoke('ai_conversation_switch', { conversationId })
+59 -3
View File
@@ -166,11 +166,13 @@
</button> </button>
<template v-if="recoverableTasks.length"> <template v-if="recoverableTasks.length">
<div class="ai-running-tasks-sep"></div> <div class="ai-running-tasks-sep"></div>
<button v-for="task in recoverableTasks" :key="task.id" type="button" class="ai-running-task-item" @click="switchToRunning(task.id)"> <div v-for="task in recoverableTasks" :key="task.id" class="ai-running-task-item ai-running-task-item--parked">
<button type="button" class="ai-running-task-main" @click="switchToRunning(task.id)">
<span class="ai-running-task-dot ai-running-task-dot--parked"></span> <span class="ai-running-task-dot ai-running-task-dot--parked"></span>
<span class="ai-running-task-title">{{ task.title }}</span> <span class="ai-running-task-title">{{ task.title }}</span>
<span class="ai-running-task-round ai-running-task-round--parked">{{ t('aiChat.recoverableTask') }}</span>
</button> </button>
<button type="button" class="ai-running-task-recover" @click="recoverParked(task.id)" :title="t('aiChat.recoverNow')">{{ t('aiChat.recoverNow') }}</button>
</div>
</template> </template>
</div> </div>
</Transition> </Transition>
@@ -487,6 +489,21 @@ async function switchToRunning(id: string) {
await switchConversation(id) await switchConversation(id)
} }
} }
/** B4:恢复 parked 会话(注入恢复引导消息续跑),成功后切回该会话 */
const recoveringIds = ref(new Set<string>())
async function recoverParked(id: string) {
if (recoveringIds.value.has(id)) return
recoveringIds.value.add(id)
try {
await aiApi.recoverParked(id)
recoveringIds.value.delete(id)
await switchToRunning(id)
} catch (e) {
recoveringIds.value.delete(id)
console.warn('[topbar] 恢复失败:', e)
}
}
const taskProgressText = computed(() => { const taskProgressText = computed(() => {
const p = taskProgress.value const p = taskProgress.value
if (!p) return '' if (!p) return ''
@@ -1112,9 +1129,48 @@ onBeforeUnmount(() => {
.ai-running-task-dot--parked { .ai-running-task-dot--parked {
background: var(--df-warning, #e6a23c); background: var(--df-warning, #e6a23c);
} }
.ai-running-task-round--parked { /* B4 parked 行改双钮布局(切回 | 恢复),恢复钮琥珀描边醒目 */
.ai-running-task-item--parked {
display: flex;
align-items: center;
gap: 6px;
padding: 0;
}
.ai-running-task-item--parked:hover {
background: none;
}
.ai-running-task-main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
padding: 5px 0 5px 10px;
border: none;
background: none;
cursor: pointer;
text-align: left;
font-size: 12px;
color: var(--df-text);
}
.ai-running-task-main:hover {
background: var(--df-hover, rgba(128,128,128,0.08));
}
.ai-running-task-recover {
flex-shrink: 0;
margin-right: 10px;
padding: 2px 8px;
border: 1px solid var(--df-warning, #e6a23c);
border-radius: 4px;
background: none;
cursor: pointer;
font-size: 11px;
color: var(--df-warning, #e6a23c); color: var(--df-warning, #e6a23c);
} }
.ai-running-task-recover:hover {
background: var(--df-warning, #e6a23c);
color: #fff;
}
.ai-history-inline-list { .ai-history-inline-list {
position: absolute; position: absolute;
left: auto; right: 0; left: auto; right: 0;
+1
View File
@@ -270,6 +270,7 @@ export default {
runningRounds: '{n} rounds running', runningRounds: '{n} rounds running',
untitledConversation: 'Untitled conversation', untitledConversation: 'Untitled conversation',
recoverableTask: 'Interrupted · recoverable', recoverableTask: 'Interrupted · recoverable',
recoverNow: 'Resume',
viewHistory: 'View history messages', viewHistory: 'View history messages',
viewSummary: 'View context summary', viewSummary: 'View context summary',
viewContext: 'View AI context', viewContext: 'View AI context',
+1
View File
@@ -271,6 +271,7 @@ export default {
runningRounds: '进行中 {n} 轮', runningRounds: '进行中 {n} 轮',
untitledConversation: '未命名会话', untitledConversation: '未命名会话',
recoverableTask: '中断·可恢复', recoverableTask: '中断·可恢复',
recoverNow: '恢复',
viewHistory: '查看历史消息', viewHistory: '查看历史消息',
viewSummary: '查看上下文摘要', viewSummary: '查看上下文摘要',
viewContext: '查看 AI 上下文', viewContext: '查看 AI 上下文',