修复: 飞书交互批: 推送汇总卡/伪受理/回查/watcher弃守/status/md净化

- 34 push_to_chat 滚动汇总卡: 同 chat 在途事务卡原地追加, ✅/❌ 终态另起新卡, 更新失败回退重绑; L3 过程不再逐条新开卡片刷屏
- 20 伪受理断层: 发票前过 can_accept_request 守卫, 不可启动回实况话术; inflight 移交 watcher 持有, 运行期计数不随 handler 返回归零
- 22 兜底回查限定受理时间戳边界(纯函数+单测), 缺时间戳视为边界外, 禁投旧回答冒充
- 23 watcher 消费本会话 AiError 视同终局投失败卡, 不再空挂 70min
- 26 话术改实况: 未受理(暂不入队)可稍后重发, 删假排队承诺
- 27 快任务兜底改受理卡发出后 8s 宽限窗判定, 替换入场恒假死分支
- 21 /status 命令桌面入口: ai_chat_send 拦截, 零 LLM 零 spawn 返回进度摘要
- 38 出站 md 净化: 标题转粗体/表格转列表/围栏保留, feishu_channel 与 df-im-gateway 同规则互引
- 受理卡符号对齐规范(任务开始用🚀, ✅仅完成)
- 验收: cargo check 零 error + feishu 13/df-im-gateway 19/chat 17 passed + vue-tsc 0
This commit is contained in:
lxy
2026-10-06 23:48:29 +08:00
parent 9520c198a2
commit 1ce1360a0b
4 changed files with 666 additions and 77 deletions
+70 -19
View File
@@ -260,7 +260,7 @@ async fn handle_card_action(events: Sender<InboundEvent>, action: larksuite_oapi
/// 构造审批卡片(Card JSON 1.0):标题 + 详情 + 同意/拒绝按钮
fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
let detail_md = format!("{}\n\n**审批 ID**: `{approval_id}`", escape_md(detail));
let detail_md = format!("{}\n\n**审批 ID**: `{approval_id}`", md_to_lark_friendly(detail));
let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue {
serde_json::from_value(serde_json::to_value(v).expect("serde_json::Value 可逆"))
.expect("按钮 value 结构兼容")
@@ -285,19 +285,54 @@ fn build_approval_card(approval_id: &str, title: &str, detail: &str) -> Card {
.element(Element::Action(actions))
}
/// Markdown 转义(飞书卡片 lark_md 语法)
fn escape_md(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('*', "\\*")
.replace('_', "\\_")
.replace('`', "\\`")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('<', "\\<")
.replace('>', "\\>")
.replace('(', "\\(")
.replace(')', "\\)")
.replace('#', "\\#")
/// 出站 Markdown 净化(飞书卡片 lark_md 语法适配)。
///
/// AI 产出常带标准 Markdown 结构,lark_md 不支持标题与表格语法,原样注入按字面量渲染。
/// 规则(与 src-tauri feishu_channel.rs 侧 md_to_lark_friendly 同规则互引,双端出站口径一致):
/// 标题行(行首一个以上 #)转粗体行;表格行(| 开头)转列表行,表头分隔行整行跳过;
/// 代码块围栏内外均保留原样(转义或改写破坏代码展示);其余行原样透传(lark_md 原生
/// 支持粗体/链接/行内代码,不额外转义)。
fn md_to_lark_friendly(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_code = false;
for line in s.lines() {
if line.trim_start().starts_with("```") {
in_code = !in_code;
out.push_str(line);
out.push('\n');
continue;
}
if in_code {
out.push_str(line);
out.push('\n');
continue;
}
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
let title = trimmed.trim_start_matches('#').trim();
if !title.is_empty() {
out.push_str(&format!("**{title}**\n"));
}
continue;
}
if trimmed.starts_with('|') {
let cells: Vec<&str> = trimmed.trim_matches('|').split('|').map(str::trim).collect();
// 表头分隔行(|---|:---|)无内容语义,整行跳过
if cells.iter().all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':')) {
continue;
}
let shown: Vec<&str> = cells.into_iter().filter(|c| !c.is_empty()).collect();
out.push_str(&format!("- {}\n", shown.join(" · ")));
continue;
}
out.push_str(line);
out.push('\n');
}
// lines() 逐行补换行,源文本不以换行结尾时尾部多出一个,与源对齐
if !s.ends_with('\n') {
out.pop();
}
out
}
#[cfg(test)]
@@ -349,11 +384,27 @@ mod tests {
assert!(approved_flags.contains(&true) && approved_flags.contains(&false));
}
/// Markdown 转义:卡片详情中的 markdown 语法字符被转义
/// 出站净化:标题转粗体行,表格行转列表行,表头分隔行跳过
#[test]
fn escape_md_works() {
assert_eq!(escape_md("a*b_c`d\\e"), "a\\*b\\_c\\`d\\\\e");
// 走查 #7:链接/标题/引用语法字符也须转义,防用户内容注入卡片结构
assert_eq!(escape_md("[x](http://e)#h <b>(y)"), "\\[x\\]\\(http://e\\)\\#h \\<b\\>\\(y\\)");
fn lark_friendly_converts_headings_and_tables() {
let md = "# 标题\n| a | b |\n|---|---|\n| 1 | 2 |";
assert_eq!(md_to_lark_friendly(md), "**标题**\n- a · b\n- 1 · 2");
}
/// 出站净化:代码围栏内外均保留原样,围栏外的标题照常改写
#[test]
fn lark_friendly_preserves_code_fence() {
let md = "前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n## 后文";
assert_eq!(
md_to_lark_friendly(md),
"前文\n```\n# 不是标题\n| 不 | 是表格 |\n```\n**后文**"
);
}
/// 出站净化:普通行原样透传(lark_md 原生支持粗体/链接,不额外转义)
#[test]
fn lark_friendly_passes_plain_lines() {
let md = "**粗** 与 [链接](http://e)\n普通行";
assert_eq!(md_to_lark_friendly(md), md);
}
}
@@ -654,6 +654,14 @@ pub async fn ai_chat_send(
// None/空 → 无 @ mention(向后兼容,仅 / 技能走 skill 参数);非空 → 按 kind 投影成 Augmentation。
mention_spans: Option<Vec<MentionSpanDto>>,
) -> Result<String, String> {
// /status 进度查询命令(2026-10-06):双端话术引用 /status 但命令此前不存在,
// 发送即被当任务烧一轮 LLM。置于技能解析之前拦截,零 LLM 零 spawn。飞书渠道在
// feishu_channel.rs 受理前有同命令拦截,本分支补齐桌面入口。
if message.trim() == "/status" {
return reply_status_summary(&app, &state, conversation_id.clone())
.await
.map(|_| "ok".to_string());
}
// H1 根修:前导 `/技能名` 解析。此前技能唯一触发路是前端浮层选中(skill 参数),
// 用户直接打字 `/name xxx` 时整串当普通消息,技能注入零发生。
// 此处对照 skills_cached() 已扫技能名(含 commands/*.md 来源)匹配前导斜杠令牌,
@@ -692,6 +700,87 @@ pub async fn ai_chat_send(
.map(|_| "ok".to_string())
}
/// /status 进度查询(2026-10-06):命令拦截应答,零 LLM 零 spawn。
///
/// 读 conv_states 生命周期态 + per_conv 轮次/最近输出,拼几行摘要作为 assistant 消息落库。
/// 事件序列与常规轮收尾同契约(AiAgentRound 建泡 → AiTextDelta 填文 → AiCompleted 复位),
/// 缺完成事件前端 doSend 已置的 streaming 无法复位即悬挂。不迁移 ConvState(只读诊断),
/// 不写 working_context(/status 是探针不是任务目标)。
///
/// 会话定位与 spawn_agentic_session 同序(入参优先 → active);无会话/会话已删时 Err 透出
/// (前端 toast),零落库零事件。轮次/目标快照短锁读即释,不与 loop 写交叉。
async fn reply_status_summary(
app: &AppHandle,
state: &AppState,
conversation_id: Option<String>,
) -> Result<(), String> {
let target = match conversation_id.filter(|s| !s.is_empty()) {
Some(id) => id,
None => state
.ai_session
.lock()
.await
.active_conversation_id
.clone()
.ok_or_else(|| "📭 当前无会话,发送消息即自动创建。".to_string())?,
};
let (state_line, pinned_goals) = {
let cs = state.conv_states.get(&target);
let session = state.ai_session.lock().await;
let conv = session
.conv_read(&target)
.ok_or_else(|| "📭 会话不存在或已删除。".to_string())?;
let line = match cs {
ConvState::Generating => {
format!("🚀 任务执行中(已 {} 轮),完成后自动回填本会话。", conv.iteration_used)
}
ConvState::Compressed => "⏳ 上下文压缩中,完成后自动续跑。".to_string(),
ConvState::Stopping => "⏹ 收到停止指令,正在收尾退出。".to_string(),
ConvState::Error => "❌ 上次任务出错,处理后重发消息即可重试。".to_string(),
ConvState::Idle => "✅ 会话空闲,无进行中任务。".to_string(),
};
(line, conv.pinned_goals.clone())
};
// 最近输出预览(复用 agentic 收敛的末条 assistant 取数;空=尚无产出,首 token/工具执行中)
let recent = super::super::agentic::last_active_assistant_text(&state.ai_session, &target).await;
let recent_line = if recent.trim().is_empty() {
"最近输出:暂无".to_string()
} else {
let snippet: String = recent.chars().take(80).collect();
format!("最近输出:{snippet}")
};
let summary = format!("{state_line}\n{recent_line}");
// 问答成对落库(重开可见,LLM 历史连贯);save 增量路径与 loop 同 per-conv 锁串行
{
let mut session = state.ai_session.lock().await;
let conv = session.conv(&target);
conv.messages.push(ChatMessage::user("/status"));
conv.messages.push(ChatMessage::assistant(summary.clone()));
}
save_conversation(&state.ai_session, &state.db, &target, None, None, true).await;
// pinned_goals 原样透传防前端目标区回退(与 agentic completed_event 同口径)
emit_ai_chat_event(app, AiChatEvent::AiAgentRound { round: 1, conversation_id: Some(target.clone()) });
emit_ai_chat_event(app, AiChatEvent::AiTextDelta { delta: summary, conversation_id: Some(target.clone()) });
emit_ai_chat_event(
app,
AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
prompt_cache_hit_tokens: 0,
prompt_cache_miss_tokens: 0,
reasoning_tokens: 0,
is_estimated: false,
incomplete: None,
incomplete_reason: None,
conversation_id: Some(target),
pinned_goals,
},
);
Ok(())
}
/// 发送消息并启动 agentic 会话的共用核心(纯搬迁自 ai_chat_send,行为逐字等价)。
///
/// 调用方:ai_chat_send IPC(前端对话,pin_active=true)/ 工作流 AiAgentNode 编排会话
+340 -58
View File
@@ -26,11 +26,22 @@ const KV_FEISHU_CONFIG: &str = "feishu-config";
/// app_settings 键:飞书 chat_id → conversation_id 映射(持久化,跨重启续会话)
const KV_CHAT_MAP: &str = "feishu-chat-map";
/// #26 占用期话术改实况:委托制下无真排队(不入队),话术禁承诺「任务完成后处理」,
/// 如实告知未受理并给出路。#20 受理守卫拒绝与 spawn 并发护栏拒绝共用同一口径
const QUEUE_BUSY_MSG: &str =
"⏳ 该会话已有任务占用中,本条未受理(暂不入队);可稍后重发,或先 /status 查看进度。";
/// 19 outbound 统一出口:渠道启动成功后的全局 client 句柄(once 填充,渠道停用为空)。
/// push_to_chat 主动推送复用长连接 client,不临时建连;进程生命周期内渠道只启一次,
/// OnceLock 语义匹配(重启应用才可能重配)。
static ACTIVE_CLIENT: std::sync::OnceLock<&'static LarkClient> = std::sync::OnceLock::new();
/// 34 滚动汇总卡句柄:chat_id 对 (在途事务卡 message_id, 已累计正文)。同 chat 多步
/// 进度推送原地追加为一卡,防逐条直推刷屏;内存句柄重启即失(重启后首推另起新卡)。
/// std Mutex 只做句柄与字符串存取,不跨 await 持有,短锁语义
static ROLLING_CARDS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, (String, String)>>> =
std::sync::OnceLock::new();
#[derive(Debug, Clone, Deserialize)]
struct FeishuConfig {
#[serde(default)]
@@ -62,7 +73,8 @@ struct Ctx {
chat_locks: Arc<tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
/// D3 活性时间戳(unix 秒):入站事件与连接建立成功时更新,看门狗据此判事件断流
last_alive: Arc<AtomicI64>,
/// D3 在途会话数:spawn 到投递完成期间 >0,看门狗要求为 0 才告警,防有回复待投递时误报
/// D3 在途会话数:消息受理(handle_message 白名单通过)到任务投递完成期间 >0,
/// 看门狗要求为 0 才告警,防有回复待投递时误报
inflight: Arc<AtomicUsize>,
}
@@ -241,6 +253,13 @@ async fn handle_message(
// D3:任何入站消息都是事件管道存活证据,先于一切过滤更新活性时间戳
ctx.last_alive.store(now_secs(), Ordering::SeqCst);
if msg.message_type != "text" {
// #25 非文本消息静默丢修复:回提示卡(D7 媒体能力前的兜底),不再零反馈
let _ = reply_markdown_card(
&ctx.client,
&msg.message_id,
"📎 暂仅支持文本消息,图片/文件等媒体能力规划中",
)
.await;
return;
}
if msg.chat_type == "group" && !msg.mentioned_bot {
@@ -287,6 +306,34 @@ async fn handle_message(
return;
}
// #21 /status 查询当前会话任务状态(话术曾引用不存在的命令,用户发 /status 被当任务
// 烧一轮 LLM)。只读不 spawn:查 conv 绑定与活跃态给进度摘要。
if text == "/status" {
let state = ctx.app.state::<crate::state::AppState>();
let conv_id = ctx.chat_map.lock().await.get(&chat_id).cloned();
let reply = match conv_id {
None => "📭 当前无绑定会话,发送消息即自动创建。".to_string(),
Some(cid) => {
let short = &cid[..8.min(cid.len())];
match state.conv_states.get(&cid) {
s if s.is_active() => format!("🚀 会话 `{short}` 任务执行中,完成后自动推送结果;可发消息注入补充指令(下一轮生效)。"),
crate::commands::ai::agentic::conv_state::ConvState::Compressed => {
format!("⏳ 会话 `{short}` 压缩暂存中,稍后自动续跑。")
}
crate::commands::ai::agentic::conv_state::ConvState::Stopping => {
format!("⏹ 会话 `{short}` 正在停止。")
}
crate::commands::ai::agentic::conv_state::ConvState::Error => {
format!("❌ 会话 `{short}` 上次任务出错,重发消息即可重试。")
}
_ => "✅ 会话空闲,无进行中任务。".to_string(),
}
}
};
reply_message(&ctx.client, &message_id, &chat_id, &reply).await;
return;
}
// ── 纠偏注入旁路(todo.md 16,2026-10-06):会话运行中 → 直接注入运行中 conv,不排队 ──
// 排队语义的痛点:per-chat 锁把补充指令排到 agent 完成后才处理,长任务期间纠偏无效。
// 注入语义:conv 活跃(Generating/Compressed)时直接写 per_conv.messages,loop 下一轮
@@ -329,6 +376,14 @@ async fn handle_message(
// D3 在途计数已前移至 handle_message 白名单通过处(探活口径修正,18),此处不再重复计数
let state = ctx.app.state::<crate::state::AppState>();
// #20 受理守卫:发票(受理卡)前先过引擎读侧闸。不可启动(前任务占用/压缩暂存/停止中)
// 直接回实况话术,不发票、不 spawn、不注册桥与流式任务,堵「受理卡已发后续零通知」
// 的伪受理断层(13:38 实证形态)
if !state.conv_states.can_accept_request(&conv_id) {
feishu_log(&format!("受理守卫拒绝 chat={chat_id} conv={conv_id}"));
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, QUEUE_BUSY_MSG).await;
return;
}
// 通知桥注册(#14):oneshot 接收端有意 drop——两段式下终版走事件广播
// (deliver_completion_report_if_waited 的 AiAgentFinished publish),桥条目存在的
// 唯一作用是 has_agent_completion_waiter 为真(触发引擎层取报告+广播);rx.drop 触发
@@ -384,7 +439,8 @@ async fn handle_message(
// 吞掉真实原因,用户误判 bot 坏了重试白撞。按引擎层错误文案分类:
// 生成中→明示任务进行中不会丢;其余→附截断原始原因(不吞因)。
let user_msg = if e.contains("正在生成") || e.contains("生成中") {
"⏳ 该会话已有任务进行中,本条将在任务完成后处理;可稍后重发,或先 /status 查看进度。".to_string()
// #26 占用期话术改实况:委托制无真排队,禁承诺「任务完成后处理」
QUEUE_BUSY_MSG.to_string()
} else {
format!("❌ 会话启动失败:{}`,请稍后重试", truncate(&e, 120))
};
@@ -411,28 +467,33 @@ async fn handle_message(
// 彻底消除「受理卡与终版两个 HTTP 请求网络乱序,终版被受理卡覆盖丢失」的竞态窗口。
drop(deliver_tx);
let _ = stream_task.await; // 等流式任务退出(快,只走通道关闭分支)
let ack = format!("✅ 已受理,后台执行中\n任务:{task_brief}");
let ack = format!("🚀 已受理,后台执行中\n任务:{task_brief}");
feishu_log(&format!("受理返回 chat={chat_id} conv={conv_id}"));
// 完成订阅任务:独立于消息处理生命周期,直到 AiAgentFinished 广播到达投递终版。
// (spawn Err 路径在上方已 return,永不到达此处——watcher 依赖引擎层正常完成广播)
// #20 附带:inflight 生命周期跟任务走,guard 移交 watcher 持有。原 guard 随本函数
// 返回即 drop,受理后任务还在后台跑计数却归零,探活误报窗口重开;任务终局(watcher
// 各退出路径)guard drop 自然回落。与本函数前置计数的短暂重叠对看门狗无影响(判据 >0)
let inflight_guard = InflightGuard(ctx.inflight.clone());
tauri::async_runtime::spawn(async move {
run_completion_watcher(ctx, conv_id, placeholder_id, message_id, chat_id, ack).await;
run_completion_watcher(ctx, conv_id, placeholder_id, message_id, chat_id, ack, inflight_guard)
.await;
});
}
/// 完成事件订阅任务(#14 两段式):占位卡唯一写者。入场先投受理卡,再消费
/// ai_event_bus 的 AiAgentFinished(本 conv)原地 edit_card 为终版。引擎层
/// deliver_completion_report_if_waited 是广播单一发出点,watcher 纯消费无状态。
/// #27 快任务宽限窗:受理卡发出后的事件驱动判定窗(原入场 elapsed>=3s 恒假死分支)。
/// 窗内到达的完成事件由广播 rx 缓冲零丢失;窗结束时以「conv 非活跃 + 边界内有产出」
/// 判定秒级任务已完成。8s 覆盖秒级任务;长任务仅推迟主循环进入,不影响终版到达时刻
const FAST_TASK_GRACE: std::time::Duration = std::time::Duration::from_secs(8);
/// 完成事件订阅任务(#14 两段式):占位卡唯一写者。先投受理卡,再消费 ai_event_bus 的
/// AiAgentFinished(本 conv)原地 edit_card 为终版。引擎层 deliver_completion_report_if_waited
/// 是广播单一发出点,watcher 纯消费无状态。
///
/// 快任务竞态兜底(走查🟡#1 加门槛):广播可能先于订阅(秒级任务)——立即回查
/// 一次,但须同时满足 conv 非活跃 + 回查时间晚于受理(防 spawn 返回但 loop 尚未
/// 置 active 的时序窗口误判) + 文本非空,三者齐才直投终版跳过受理卡(卡片写入
/// 单任务串行,无网络乱序覆盖)。任一不满足则先落受理卡再进事件等待。
///
/// 超时语义(走查🟡#2 修正):35min(> loop 30min 上限)超时投降级提示后**不退出**,
/// 继续挂事件等待——任务后台不丢,晚到的完成事件仍会送到卡片;watcher 与任务
/// 同生命周期,彻底无果(引擎侧异常未广播)才在二次超时后弃守。
/// 超时语义:35min(> loop 30min 上限)首超时回查边界内产出,无果投降级提示但不退出
/// (任务后台不丢,晚到完成事件仍送达);二次超时真弃守(#23:两轮均无事件且无边界内
/// 产出,认任务失联,停止重刷)。
async fn run_completion_watcher(
ctx: Arc<Ctx>,
conv_id: String,
@@ -440,47 +501,73 @@ async fn run_completion_watcher(
message_id: String,
chat_id: String,
ack: String,
// #20:计数生命周期跟任务走,guard 持有至 watcher 退出
_inflight: InflightGuard,
) {
let state = ctx.app.state::<crate::state::AppState>();
let mut rx = state.ai_event_bus.subscribe();
let accepted_at = tokio::time::Instant::now();
// 快任务兜底:立即回查一次(事件可能已错过);三分量门槛防时序窗口误判
let settled = !state.conv_states.is_active(&conv_id)
&& accepted_at.elapsed() >= std::time::Duration::from_secs(3)
&& matches!(last_assistant_reply(&state.db, &conv_id).await,
Some(t) if !t.trim().is_empty());
if settled {
let text = last_assistant_reply(&state.db, &conv_id)
.await
.unwrap_or_default();
feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", text.len()));
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await;
return;
}
// #22 受理时刻(Unix 毫秒):快任务兜底与超时回查只认此后落库的 assistant 消息,
// 防把上一任务旧回答冒充本次结果(无 timestamp 的老消息视为不在边界内)
let accepted_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
// 落受理卡(占位缺失回退新发,deliver_result 内部链路)
deliver_result(&ctx.client, &placeholder_id.clone(), &message_id.clone(), &chat_id.clone(), &ack).await;
// 事件等待循环:首超时 35 分钟投降级提示不退出,继续等晚到事件;二次超时弃守。
// #27 快任务兜底:受理卡发出后开宽限窗再判定,事件驱动替代恒假的时间比较。
// 秒级任务的完成事件可能先于本订阅已广播完(错过),窗结束时 conv 已归非活跃且
// 边界内有产出,即判快任务已完成,直投终版
tokio::time::sleep(FAST_TASK_GRACE).await;
if !state.conv_states.is_active(&conv_id) {
if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
if !t.trim().is_empty() {
feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len()));
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &t).await;
return;
}
}
}
// 事件等待循环:首超时投降级提示不退出;二次超时真弃守(#23)
let timeout = std::time::Duration::from_secs(2100);
let mut timeouts = 0usize;
loop {
let text = tokio::select! {
item = rx.recv() => match item {
Ok(v) => {
if v.get("type").and_then(|t| t.as_str()) != Some("AiAgentFinished") {
continue;
}
if v.get("conversation_id").and_then(|c| c.as_str()) != Some(conv_id.as_str()) {
continue;
}
// 广播语义:agent_id==conv_id 时 summary 即本会话最终报告(引擎层构造)
v.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string()
match v.get("type").and_then(|t| t.as_str()) {
// #23 防任务泄漏:引擎错误路径只发 AiError 不发 AiAgentFinished(引擎侧
// 补广播在 chat.rs 域,本文件域禁触),渠道侧消费 AiError 视同终局,
// watcher 即刻收口,不再空挂到双超时弃守
Some("AiError") => {
let err = v.get("error").and_then(|s| s.as_str()).unwrap_or("未知错误");
feishu_log(&format!("watcher 收到 AiError conv={conv_id}"));
format!("❌ 任务执行失败:{},可稍后重试,或发 /status 查看会话状态。", truncate(err, 300))
}
Some("AiAgentFinished") => {
// 广播语义:agent_id==conv_id 时 summary 即本会话最终报告(引擎层构造)
v.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string()
}
_ => continue,
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
},
_ = tokio::time::sleep(timeout) => {
timeouts += 1;
// 二次超时弃守:两轮 35min 均无事件且无边界内产出,认任务失联,停止重刷
if timeouts >= 2 {
let give_up = "⏰ 任务超 70 分钟无任何结果(已停止等待;引擎仍在后台运行则结果稍后可在桌面端查看;可发 /status 查询)。".to_string();
feishu_log(&format!("watcher 二次超时弃守 conv={conv_id}"));
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &give_up).await;
return;
}
// 首超时:DB 回查兜底(任务可能刚完成事件已发完);回查有果照投终版,
// 无果投降级提示并**继续等待**(走查🟡#2:超时不弃投递,晚到事件仍送达)
match last_assistant_reply(&state.db, &conv_id).await {
// 无果投降级提示并继续等待(晚到事件仍送达)
match last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
Some(t) if !t.trim().is_empty() => t,
_ => {
let notice = "⏰ 任务执行超 35 分钟仍未返回(后台仍在运行,完成后本卡片自动更新;或本会话 /status 查询)。".to_string();
@@ -662,15 +749,23 @@ fn stream_preview(
format!("{head}{body}\n▌")
}
/// markdown 回复:interactive 卡片承载(lark_md 原生渲染标题/列表/粗体,text 类型会符号裸露)
async fn send_markdown_card(client: &LarkClient, message_id: &str, chat_id: &str, text: &str) {
/// markdown 回复:interactive 卡片承载(lark_md 原生渲染标题/列表/粗体,text 类型会符号裸露)。
/// 出站先过 38a 净化(md_to_lark_friendly),成功返回新卡片 message_id(34 滚动汇总卡
/// 绑定句柄用,取 id 方式与 reply_markdown_card 同款);彻底失败回退纯文本保可达,返 None
async fn send_markdown_card(
client: &LarkClient,
message_id: &str,
chat_id: &str,
text: &str,
) -> Option<String> {
let text = &md_to_lark_friendly(text);
let card = Card::new().element(Element::Markdown(MdElement::new(text.to_string())));
let document = match CardDocument::new(card) {
Ok(d) => d,
Err(e) => {
tracing::warn!("[feishu] 卡片校验失败,回退纯文本: {e}");
reply_message(client, message_id, chat_id, text).await;
return;
return None;
}
};
let content = serde_json::to_string(document.card()).unwrap_or_default();
@@ -680,16 +775,25 @@ async fn send_markdown_card(client: &LarkClient, message_id: &str, chat_id: &str
..Default::default()
};
let opt = &RequestOption::default();
let sent = if !message_id.is_empty() {
client.channel_messaging().reply(message_id, &input, opt).await.is_ok()
} else {
false
};
if !sent {
if let Err(e) = client.channel_messaging().send(&input, opt).await {
let mut replied: Option<String> = None;
if !message_id.is_empty() {
if let Ok(sent) = client.channel_messaging().reply(message_id, &input, opt).await {
if !sent.message_id.is_empty() {
replied = Some(sent.message_id);
}
}
}
if replied.is_some() {
return replied;
}
match client.channel_messaging().send(&input, opt).await {
Ok(sent) if !sent.message_id.is_empty() => Some(sent.message_id),
Ok(_) => None,
Err(e) => {
tracing::error!("[feishu] 卡片发送失败: {e}");
// 卡片彻底失败仍回退纯文本,保可达
reply_message(client, message_id, chat_id, text).await;
None
}
}
}
@@ -754,8 +858,9 @@ async fn update_card(client: &LarkClient, message_id: &str, text: &str) -> bool
}
}
/// 结果投递统一入口:先做表格降级(md 表格在卡片 markdown 组件渲染不稳),
/// 优先原地更新占位卡片(占位与结果合一);占位缺失或更新失败回退新发卡片(内含纯文本兜底)
/// 结果投递统一入口:先做出站净化(38a,update_card 原地路径不经 send_markdown_card,
/// 须在此净化;回退新发路径二次进入幂等)与裁剪,优先原地更新占位卡片(占位与结果合一);
/// 占位缺失或更新失败回退新发卡片(内含纯文本兜底)
async fn deliver_result(
client: &LarkClient,
placeholder_id: &Option<String>,
@@ -763,20 +868,24 @@ async fn deliver_result(
chat_id: &str,
text: &str,
) {
let text = clip_for_card(&downgrade_md_tables(text), 8000);
let text = clip_for_card(&md_to_lark_friendly(text), 8000);
if let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) {
if update_card(client, pid, &text).await {
return;
}
feishu_log("占位卡片更新失败,回退新发卡片");
}
send_markdown_card(client, message_id, chat_id, &text).await;
let _ = send_markdown_card(client, message_id, chat_id, &text).await;
}
/// 19 outbound 统一出口:主动推送(无占位卡上下文的外部推送方——挂机进度汇总/巡检通知
/// 等将来形态)。降级/裁剪/卡片链与 deliver_result 同源(禁再开独立发送路径绕过降级链,
/// 11:23 实证:绕过链的回复带完整 md 表格未降级,踩回表格渲染不稳坑)。
/// 34 滚动汇总卡:同 chat 在途事务卡存在时原地追加(句柄见 ROLLING_CARDS),✅/❌ 终态
/// 更新后清句柄下条另起新卡;原地更新失败清句柄回退新发并重绑(比照 deliver_result 兜底)。
/// 推送目标:chat_map 最近映射的会话;渠道未启动(无 client)或无映射时静默跳过。
/// 19 正典出口预留给将来推送方(挂机进度汇总/巡检通知),当前无调用方
#[allow(dead_code)]
pub(crate) async fn push_to_chat(app: &tauri::AppHandle, text: &str) {
let Some(client) = ACTIVE_CLIENT.get() else {
return; // 渠道未启动:推送无落点,静默跳过(调用方自行决定是否记日志)
@@ -792,10 +901,46 @@ pub(crate) async fn push_to_chat(app: &tauri::AppHandle, text: &str) {
let Some(chat_id) = map.keys().next().cloned() else {
return;
};
let processed = clip_for_card(&downgrade_md_tables(text), 8000);
// 34 终态判据:✅/❌ 开头即事务收尾,更新后不再续卡
let terminal = is_terminal_push(text);
let rolling = ROLLING_CARDS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let handle = rolling.lock().ok().and_then(|m| m.get(&chat_id).cloned());
if let Some((card_id, mut acc)) = handle {
acc.push('\n');
acc.push_str(text);
let rendered = clip_for_card(&md_to_lark_friendly(&acc), 8000);
if update_card(client, &card_id, &rendered).await {
if terminal {
if let Ok(mut m) = rolling.lock() {
m.remove(&chat_id);
}
}
feishu_log(&format!("push_to_chat 追加 chat={chat_id} len={}", rendered.len()));
return;
}
// 原地更新失败(卡片被撤回/JSON 超限等):清句柄,新发并重绑
if let Ok(mut m) = rolling.lock() {
m.remove(&chat_id);
}
let rebound = send_markdown_card(client, "", &chat_id, &rendered).await;
if let (false, Some(new_id)) = (!terminal, rebound) {
if let Ok(mut m) = rolling.lock() {
m.insert(chat_id.clone(), (new_id, acc));
}
}
feishu_log(&format!("push_to_chat 回退重绑 chat={chat_id} len={}", rendered.len()));
return;
}
let processed = clip_for_card(&md_to_lark_friendly(text), 8000);
// 主动推送无 message_id 上下文:send_markdown_card 的 reply 分支跳过,直接 chat 卡片
send_markdown_card(client, "", &chat_id, &processed).await;
feishu_log(&format!("push_to_chat chat={chat_id} len={}", processed.len()));
let sent_id = send_markdown_card(client, "", &chat_id, &processed).await;
// 首推开卡绑定句柄;终态不绑,下条另起新卡
if let (false, Some(new_id)) = (!terminal, sent_id) {
if let Ok(mut m) = rolling.lock() {
m.insert(chat_id.clone(), (new_id, text.to_string()));
}
}
feishu_log(&format!("push_to_chat chat={chat_id} len={} terminal={terminal}", processed.len()));
}
/// 卡片正文长度防御:飞书卡片 JSON 约 30KB 上限,超长会让原地更新与新发双双失败,
@@ -890,6 +1035,67 @@ fn is_table_row(line: &str) -> bool {
line.trim_start().starts_with('|')
}
/// 38a 出站 md 净化:md 转飞书友好格式。lark_md 不支持标题/表格等全谱语法,LLM 产出的
/// #/##/| 原样出站即符号裸露(38 实证)。规则母本三条:标题行转粗体行、表格转列表行
/// (复用 downgrade_md_tables 单一实现)、代码块围栏内原样保留,其余原样。
/// 幂等:粗体行/列表行二次进入不再变形,send_markdown_card 与 push_to_chat 链重复
/// 调用安全。df-im-gateway StreamText 出站侧做净化时须对齐同一规则(同规则互引,
/// 38 双层防线的出站兜底层,以本函数为正典)。
pub fn md_to_lark_friendly(text: &str) -> String {
let mut out: Vec<String> = Vec::new();
let mut plain: Vec<&str> = Vec::new();
let mut in_fence = false;
for line in text.lines() {
if line.trim_start().starts_with("```") {
if !in_fence {
out.extend(sanitize_plain_lines(&plain));
plain.clear();
}
in_fence = !in_fence;
out.push(line.to_string());
continue;
}
if in_fence {
out.push(line.to_string());
} else {
plain.push(line);
}
}
out.extend(sanitize_plain_lines(&plain));
out.join("\n")
}
/// 围栏外逐段净化:表格降级 + 标题行转粗体行
fn sanitize_plain_lines(lines: &[&str]) -> Vec<String> {
let joined = lines.join("\n");
downgrade_md_tables(&joined)
.lines()
.map(|line| match heading_content(line) {
Some(content) if !content.is_empty() => format!("**{content}**"),
_ => line.to_string(),
})
.collect()
}
/// 标题行识别:1 到 6 个 # 后接空格(CommonMark 形态,防 #标签 之类误伤)
fn heading_content(line: &str) -> Option<&str> {
let t = line.trim_start();
let hashes = t.chars().take_while(|&c| c == '#').count();
if (1..=6).contains(&hashes) {
if let Some(rest) = t[hashes..].strip_prefix(' ') {
return Some(rest.trim_end());
}
}
None
}
/// 34 终态判据:文本以 ✅/❌ 开头(对齐看板符号语义规范:✅ 仅限完成且成功,❌ 失败须附原因)
/// 视为事务收尾,滚动汇总卡更新后清句柄,下条推送另起新卡
fn is_terminal_push(text: &str) -> bool {
let t = text.trim_start();
t.starts_with('✅') || t.starts_with('❌')
}
/// 分隔行形态:| 开头,且整行只由 | - : 与空白构成并含至少一段 ---
fn is_table_separator(line: &str) -> bool {
let t = line.trim();
@@ -935,14 +1141,30 @@ fn truncate(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
/// 回查该会话最后一条非空 assistant 文本(通知桥空内容时的事实源兜底)
async fn last_assistant_reply(db: &df_storage::db::Database, conv_id: &str) -> Option<String> {
/// #22 时间戳过滤判据(纯函数,单测锁定):给定受理时刻时只认其后落库的消息,
/// 消息缺时间戳视为不在边界内(宁缺勿冒充旧回答);无受理时刻不过滤
fn msg_after_acceptance(msg_ts: Option<i64>, accepted_ms: Option<i64>) -> bool {
accepted_ms.map_or(true, |ts| msg_ts.map_or(false, |t| t > ts))
}
/// 回查该会话最后一条非空 assistant 文本(通知桥空内容时的事实源兜底)。
/// #22 任务边界:only_after_ms 传入受理时刻(Unix 毫秒)时只认该时刻之后的消息,
/// 防 35min 兜底回查把上一任务旧回答冒充本次结果;None 不过滤(旧语义)。
async fn last_assistant_reply(
db: &df_storage::db::Database,
conv_id: &str,
only_after_ms: Option<i64>,
) -> Option<String> {
use df_storage::crud::AiMessageRepo;
let repo = AiMessageRepo::new(db);
let msgs = repo.list_by_conversation(conv_id).await.ok()?;
msgs.into_iter()
.rev()
.find(|m| m.role == "assistant" && !m.content.trim().is_empty())
.find(|m| {
m.role == "assistant"
&& !m.content.trim().is_empty()
&& msg_after_acceptance(m.timestamp, only_after_ms)
})
.map(|m| m.content)
}
@@ -954,7 +1176,7 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
#[cfg(test)]
mod tests {
use super::{strip_mention_placeholders, stream_preview};
use super::{is_terminal_push, md_to_lark_friendly, msg_after_acceptance, strip_mention_placeholders, stream_preview};
#[test]
fn mention_cn_no_space_not_swallowed() {
@@ -1012,4 +1234,64 @@ mod tests {
assert!(out.contains("…(前文已略)"));
assert!(out.chars().count() < 8500); // 头行 + 8000 尾 + 标注
}
// ── 38a 出站 md 净化 ──
#[test]
fn md_heading_to_bold_line() {
assert_eq!(md_to_lark_friendly("## 分析报告"), "**分析报告**");
assert_eq!(md_to_lark_friendly("# 总览\n正文"), "**总览**\n正文");
// 非 CommonMark 形态(# 后无空格)不误伤
assert_eq!(md_to_lark_friendly("#hashtag 保留"), "#hashtag 保留");
}
#[test]
fn md_table_to_list_rows() {
let md = "| 名称 | 数量 |\n|---|---|\n| 甲 | 1 |";
let out = md_to_lark_friendly(md);
assert_eq!(out, "- **名称**:**数量**\n- 甲:1");
}
#[test]
fn md_fence_kept_verbatim() {
// 围栏内 # 与表格语法原样保留(转换反伤代码可读性),围栏外正常净化
let md = "## 说明\n```rust\n# comment\n| a | b |\n```\n| x | y |\n|---|---|\n| 1 | 2 |";
let out = md_to_lark_friendly(md);
assert!(out.starts_with("**说明**"));
assert!(out.contains("```rust\n# comment\n| a | b |\n```"));
assert!(out.contains("- **x**:**y**"));
}
#[test]
fn md_plain_text_untouched_and_idempotent() {
let md = "普通段落 **粗体** 保留\n- 列表项";
let once = md_to_lark_friendly(md);
assert_eq!(once, md);
assert_eq!(md_to_lark_friendly(&once), once);
}
// ── 22 受理时间戳过滤 ──
#[test]
fn acceptance_window_filters_old_messages() {
// 有受理时刻:只认其后落库;边界时刻本身不算;缺时间戳的老消息视为不在边界内
assert!(msg_after_acceptance(Some(2000), Some(1000)));
assert!(!msg_after_acceptance(Some(1000), Some(1000)));
assert!(!msg_after_acceptance(Some(500), Some(1000)));
assert!(!msg_after_acceptance(None, Some(1000)));
// 无受理时刻不过滤(旧语义,调用方自担边界)
assert!(msg_after_acceptance(Some(1), None));
assert!(msg_after_acceptance(None, None));
}
// ── 34 推送终态判据 ──
#[test]
fn terminal_push_prefix_only() {
assert!(is_terminal_push("✅ 完成"));
assert!(is_terminal_push("❌ 失败:原因"));
assert!(is_terminal_push(" ✅ 缩进也算"));
assert!(!is_terminal_push("⏳ 进行中"));
assert!(!is_terminal_push("普通进度行"));
}
}
+167
View File
@@ -0,0 +1,167 @@
# DevFlow 自宿主换装脚本 v3
# 流程: 等旧进程退出并探测句柄释放, 静默安装, 按构建产物哈希校验且失败重试一次, 交互用户可见重启, 结束自清理
$ErrorActionPreference = 'Stop'
$installer = 'E:\wk-lab\devflow\target\release\bundle\nsis\devflow_0.1.0_x64-setup.exe'
$target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe'
$builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe'
$log = 'E:\wk-lab\devflow\swap-install.log'
$lockFile = 'E:\wk-lab\devflow\swap-install.lock'
# 构建产物哈希是唯一强基准; 产物缺失时退化为与安装前快照比对的弱校验
$hashWant = $null
if (Test-Path $builtExe) { $hashWant = (Get-FileHash $builtExe -Algorithm SHA256).Hash }
# 0. SYSTEM 上下文转投用户会话
$who = whoami
if ($who -like '*SYSTEM*') {
$loggedUser = (Get-CimInstance Win32_ComputerSystem).UserName
if ($loggedUser) {
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$PSCommandPath`""
$principal = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive
Register-ScheduledTask -TaskName 'DevFlowSwapUser' -Action $action -Principal $principal -Force | Out-Null
Start-ScheduledTask -TaskName 'DevFlowSwapUser'
# 锁由重入脚本创建删除, 是其存活的可靠信号; 按 task State 等有 Ready 竞态且超时会腰斩换装
$deadline = (Get-Date).AddSeconds(30)
while (-not (Test-Path $lockFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 1 }
$deadline = (Get-Date).AddSeconds(300)
while ((Test-Path $lockFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 2 }
Unregister-ScheduledTask -TaskName 'DevFlowSwapUser' -Confirm:$false -ErrorAction SilentlyContinue
} else {
"[FAIL] SYSTEM context and no logged-on user found" | Out-File $log -Encoding utf8
}
Unregister-ScheduledTask -TaskName 'DevFlowSwapInstall' -Confirm:$false -ErrorAction SilentlyContinue
exit 0
}
if (Test-Path $lockFile) {
$lockAge = (Get-Date) - (Get-Item $lockFile).LastWriteTime
if ($lockAge.TotalMinutes -lt 5) { exit 0 }
}
New-Item $lockFile -Force | Out-Null
# 独占打开目标 exe 成功即旧句柄已释放; Windows 文件锁延迟释放会让 NSIS 静默伪成功
function Wait-HandleReleased {
if (-not (Test-Path $target)) { return $true }
$deadline = (Get-Date).AddSeconds(30)
while ((Get-Date) -lt $deadline) {
try {
$fs = [IO.File]::Open($target, 'Open', 'ReadWrite', 'None')
$fs.Close()
"[$(Get-Date -Format 'HH:mm:ss')] handle released" | Add-Content $log
return $true
} catch {
Start-Sleep -Milliseconds 500
}
}
"[$(Get-Date -Format 'HH:mm:ss')] handle wait timeout, continue" | Add-Content $log
return $false
}
# 静默安装并校验新 exe, 返回换装是否成功
function Invoke-SwapInstall {
param([string]$Attempt)
$p = Start-Process $installer -ArgumentList '/S' -Wait -PassThru
"[$(Get-Date -Format 'HH:mm:ss')] installer exit=$($p.ExitCode) (attempt $Attempt)" | Add-Content $log
if ($p.ExitCode -ne 0) { return $false }
if (-not (Test-Path $target)) {
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: target missing after install" | Add-Content $log
return $false
}
$afterHash = (Get-FileHash $target -Algorithm SHA256).Hash
$afterSize = (Get-Item $target).Length
"[$(Get-Date -Format 'HH:mm:ss')] installed size=$afterSize hash=$($afterHash.Substring(0, 12))" | Add-Content $log
if ($hashWant) {
if ($afterHash -ne $hashWant) { return $false }
} else {
"[$(Get-Date -Format 'HH:mm:ss')] weak check: built exe absent, compare with pre-install hash" | Add-Content $log
if ($afterHash -eq $before.hash) { return $false }
}
return $true
}
try {
"[$(Get-Date -Format 'HH:mm:ss')] swap start (user=$(whoami))" | Out-File $log -Encoding utf8
if ($hashWant) {
"[$(Get-Date -Format 'HH:mm:ss')] verify baseline: built exe hash=$($hashWant.Substring(0, 12))" | Add-Content $log
} else {
"[$(Get-Date -Format 'HH:mm:ss')] verify baseline: weak check (built exe absent)" | Add-Content $log
}
# 1. 等旧进程退出(最多 30s), 超时强杀
$deadline = (Get-Date).AddSeconds(30)
while ((Get-Date) -lt $deadline) {
if (-not (Get-Process devflow -ErrorAction SilentlyContinue)) { break }
Start-Sleep -Milliseconds 500
}
Get-Process devflow -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Seconds 2
"[$(Get-Date -Format 'HH:mm:ss')] old process stopped" | Add-Content $log
# 2. 探测句柄释放
Wait-HandleReleased | Out-Null
# 3. 安装前快照, 供弱校验与前后比对
$before = @{ hash = 'absent'; size = 0 }
if (Test-Path $target) {
$before.hash = (Get-FileHash $target -Algorithm SHA256).Hash
$before.size = (Get-Item $target).Length
}
$beforeHashShort = $before.hash
if ($beforeHashShort.Length -gt 12) { $beforeHashShort = $beforeHashShort.Substring(0, 12) }
"[$(Get-Date -Format 'HH:mm:ss')] before size=$($before.size) hash=$beforeHashShort" | Add-Content $log
# 4. 静默安装 + 校验, 校验失败重试一次
if (Invoke-SwapInstall 'first') {
"[$(Get-Date -Format 'HH:mm:ss')] verify pass (first attempt)" | Add-Content $log
} else {
"[$(Get-Date -Format 'HH:mm:ss')] verify fail, retry once" | Add-Content $log
Wait-HandleReleased | Out-Null
if (Invoke-SwapInstall 'retry') {
"[$(Get-Date -Format 'HH:mm:ss')] verify pass (retry)" | Add-Content $log
} else {
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: verify failed twice" | Add-Content $log
exit 1
}
}
# 5. 重启并验证; GUI 必须起在交互用户会话才可见
Start-Process $target
Start-Sleep -Seconds 5
$proc = Get-Process devflow -ErrorAction SilentlyContinue | Select-Object -First 1
if ($proc) {
"[$(Get-Date -Format 'HH:mm:ss')] OK: restarted pid=$($proc.Id)" | Add-Content $log
exit 0
}
# SYSTEM 会话直启的 GUI 不可见, 转投交互登录用户名下的一次性计划任务
$explorer = Get-CimInstance Win32_Process -Filter "Name='explorer.exe'" | Select-Object -First 1
if ($explorer) {
$o = Invoke-CimMethod -InputObject $explorer -MethodName GetOwner
$user = "$($o.Domain)\$($o.User)"
try {
$action = New-ScheduledTaskAction -Execute $target
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive
Register-ScheduledTask -TaskName 'DevFlowStartOnce' -Action $action -Principal $principal -Force -ErrorAction Stop | Out-Null
Start-ScheduledTask -TaskName 'DevFlowStartOnce'
Start-Sleep -Seconds 5
} catch {
"[$(Get-Date -Format 'HH:mm:ss')] start task error: $($_.Exception.Message)" | Add-Content $log
} finally {
Unregister-ScheduledTask -TaskName 'DevFlowStartOnce' -Confirm:$false -ErrorAction SilentlyContinue
}
$proc = Get-Process devflow -ErrorAction SilentlyContinue | Select-Object -First 1
if ($proc) {
"[$(Get-Date -Format 'HH:mm:ss')] OK: restarted as $user pid=$($proc.Id)" | Add-Content $log
exit 0
}
}
# 仍无进程, 落标记等人工拉起
Set-Content -Path 'E:\wk-lab\devflow\swap-need-start.flag' -Value (Get-Date -Format o)
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: need manual start" | Add-Content $log
exit 1
}
finally {
Remove-Item $lockFile -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName 'DevFlowSwapInstall' -Confirm:$false -ErrorAction SilentlyContinue
}