zen-gitsync 2.19.5 → 2.19.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +3 -1
  2. package/src/peer/localAsk.js +9 -1
  3. package/src/ui/public/assets/AgentView-CxAsrb9l.js +15 -0
  4. package/src/ui/public/assets/{AgentView-BIehoDwa.css → AgentView-D5MiIZuz.css} +1 -1
  5. package/src/ui/public/assets/{AppVersionBadge-B-6BT87B.js → AppVersionBadge-DIzhkaZi.js} +2 -2
  6. package/src/ui/public/assets/{BranchSelector-C1Kct7NG.js → BranchSelector-Cf6de-E0.js} +1 -1
  7. package/src/ui/public/assets/{CommitForm-DoC-6rft.js → CommitForm-DB0Bthai.js} +1 -1
  8. package/src/ui/public/assets/{CommonDialog-D8XaOcNX.js → CommonDialog-C83HvDae.js} +1 -1
  9. package/src/ui/public/assets/EditorView-BX7nAElt.css +1 -0
  10. package/src/ui/public/assets/EditorView-D-Y2WEod.js +1 -0
  11. package/src/ui/public/assets/{FlowExecutionViewer-CKMH34Br.js → FlowExecutionViewer-BZSHJjqL.js} +1 -1
  12. package/src/ui/public/assets/{FlowOrchestrationWorkspace-CAFDGDim.js → FlowOrchestrationWorkspace-DF0kgwRn.js} +1 -1
  13. package/src/ui/public/assets/{LogList-DB6A2R6o.js → LogList-DiZFQLB8.js} +1 -1
  14. package/src/ui/public/assets/MindmapView-CohUDqAD.css +1 -0
  15. package/src/ui/public/assets/MindmapView-DcG7Q07c.js +1 -0
  16. package/src/ui/public/assets/{MonitorView-BjUS__Ze.js → MonitorView-Dsbfq-LO.js} +1 -1
  17. package/src/ui/public/assets/{ProjectManagerView-CWX1sSgn.js → ProjectManagerView-8Z1wtUUN.js} +1 -1
  18. package/src/ui/public/assets/{ProjectStartupButton-DdMFVPYI.js → ProjectStartupButton-BJ-feAjq.js} +1 -1
  19. package/src/ui/public/assets/{RemoteManagerDialog-fSp9IiCs.js → RemoteManagerDialog-Bx4-EAbj.js} +1 -1
  20. package/src/ui/public/assets/{RemoteRepoCard-Bs01mtmx.js → RemoteRepoCard-Bq-onnB-.js} +1 -1
  21. package/src/ui/public/assets/{SourceMapView-tdQu2qjL.js → SourceMapView-DP5gJQOd.js} +1 -1
  22. package/src/ui/public/assets/{SvgIcon-D5bT7QEj.js → SvgIcon-CvIjcHNr.js} +1 -1
  23. package/src/ui/public/assets/{UserInputNode-DbP3PjQX.js → UserInputNode-mZ2YRrD3.js} +1 -1
  24. package/src/ui/public/assets/{WorkbenchView-Ahoq6RyI.js → WorkbenchView-DYy1n0I0.js} +2 -2
  25. package/src/ui/public/assets/{WorkbenchView-BcG-GrWp.css → WorkbenchView-DjAMZ-KQ.css} +1 -1
  26. package/src/ui/public/assets/{_plugin-vue_export-helper-BRUY9DoQ.js → _plugin-vue_export-helper-CfbZLRTe.js} +4 -4
  27. package/src/ui/public/assets/{configStore-1qv5tdrb.js → configStore-DdpMp1OU.js} +1 -1
  28. package/src/ui/public/assets/{index-C4masR7z.js → index-Bnk5Un7L.js} +4 -4
  29. package/src/ui/public/assets/{relativeTime-nh6F0rop.js → relativeTime-BNBUCPAl.js} +1 -1
  30. package/src/ui/public/assets/taskExecutionExport-yVOVF_bE.js +15 -0
  31. package/src/ui/public/assets/{useWorkbenchProjects-CDT0eWCI.js → useWorkbenchProjects-Bqm9XLHt.js} +1 -1
  32. package/src/ui/public/index.html +5 -5
  33. package/src/ui/server/routes/fs.js +132 -0
  34. package/src/ui/server/routes/workbench/agentRoutes.js +253 -21
  35. package/src/ui/server/routes/workbench/agentSessionEvents.js +114 -0
  36. package/src/ui/server/routes/workbench/agentSessionEvents.test.js +56 -0
  37. package/src/ui/server/routes/workbench/sessionGate.js +109 -8
  38. package/src/ui/server/routes/workbench/sessionGate.test.js +103 -0
  39. package/src/ui/public/assets/AgentView-B4p1C7vE.js +0 -15
  40. package/src/ui/public/assets/EditorView-B666I1pz.css +0 -1
  41. package/src/ui/public/assets/EditorView-DrhO2YSI.js +0 -1
  42. package/src/ui/public/assets/MindmapView-C4L5u3S9.js +0 -1
  43. package/src/ui/public/assets/MindmapView-DuaVqeHO.css +0 -1
  44. package/src/ui/public/assets/taskExecutionExport-PEcuZQo1.js +0 -15
@@ -34,11 +34,17 @@ import { createProjectListProvider } from './projectTool.js';
34
34
  import { askPeerByRef, listPeerStatus } from '../../../../peer/client.js';
35
35
  import { createDispatcher } from './dispatchInstruction.js';
36
36
  import { createWorkspaceSnapshotter } from '../aiContext/wiring.js';
37
+ import { subscribeAgentSessionChanges } from './agentSessionEvents.js';
37
38
  // 用户固定提示词(g ai 默认提示词 + 派发提示词)的解析 —— 两条引擎链路共用同一份
38
39
  import { resolveChatPromptSection, buildDispatchPolicySection } from './chatPrompts.js';
39
40
  // 「同一会话同一时刻只允许一个生成中的轮次」的闸,与定时任务调度器共享
40
41
  // (见 sessionGate.js —— 拆出去是为了让调度器复用同一份判定口径)。
41
- import { isSessionBusy, acquireSession, releaseSession } from './sessionGate.js';
42
+ import {
43
+ acquireSessionTurn,
44
+ getSessionTurn,
45
+ releaseSession,
46
+ requestSessionAbort,
47
+ } from './sessionGate.js';
42
48
  import { createScheduler } from './scheduler.js';
43
49
  import { registerScheduleRoutes } from './scheduleRoutes.js';
44
50
  import { normalizeItems, filterToRecentDirs, buildDirStatusBlock } from '../recentDirectoriesAiSummary.js';
@@ -59,6 +65,12 @@ import { resolveModels } from '../../../../config.js';
59
65
 
60
66
  const { genSessionId, autoTitle, read: readSession, write: writeSession, delete: deleteSession, listMeta: listSessionsMeta, enforceRetention, rename: renameSession } = agentSessionStore;
61
67
 
68
+ // 轮次内"检查点落盘"的节流间隔。
69
+ // 为什么不是每个 token 写一次:一轮几千个增量,磁盘会被当成缓冲区用;
70
+ // 为什么不是只在结束时写:进程崩一次整轮就没了。2 秒是这两者之间的折中 ——
71
+ // 最坏丢 2 秒、正常一轮多几十次原子写。想看实时进度走 SSE,落盘只负责"别丢"。
72
+ const CHECKPOINT_MS = 2000;
73
+
62
74
  // 项目路径规范化:Windows 路径大小写不敏感,POSIX 路径保留大小写语义。
63
75
  function normalizeCwd(p) {
64
76
  const value = String(p || '').trim();
@@ -322,6 +334,38 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
322
334
  res.json({ success: true, sessions });
323
335
  }));
324
336
 
337
+ /**
338
+ * GET /api/agent/events — SSE:会话目录变了就推一条,前端据此重拉列表。
339
+ *
340
+ * 为什么不是每个写入方各自发事件:会话文件是整机共享的,写盘方可能是远端 peer、
341
+ * 另一个 g ui 窗口、CLI、定时任务 —— 逐个接线必然会漏一个。这里盯目录本身
342
+ * (见 agentSessionEvents.js),所有写盘路径天然覆盖。
343
+ *
344
+ * 只推"变了"这一个事实,不推具体是哪条:前端拿到就重拉,口径与手动刷新完全一致,
345
+ * 不会出现两套列表合并逻辑。
346
+ */
347
+ app.get('/api/agent/events', (req, res) => {
348
+ res.set({
349
+ 'Content-Type': 'text/event-stream',
350
+ 'Cache-Control': 'no-cache, no-transform',
351
+ 'Connection': 'keep-alive',
352
+ 'X-Accel-Buffering': 'no'
353
+ });
354
+ res.flushHeaders?.();
355
+ const send = (data) => {
356
+ try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch {}
357
+ };
358
+ send({ type: 'hello' });
359
+ const dispose = subscribeAgentSessionChanges(() => send({ type: 'sessions_changed' }));
360
+ const keepAlive = setInterval(() => {
361
+ try { res.write(': keep-alive\n\n'); } catch {}
362
+ }, SSE_HEARTBEAT_MS);
363
+ req.on('close', () => {
364
+ clearInterval(keepAlive);
365
+ dispose();
366
+ });
367
+ });
368
+
325
369
  // ════════════════════════════════════════════════════════════════════════
326
370
  // §2. 会话详情
327
371
  // ════════════════════════════════════════════════════════════════════════
@@ -433,6 +477,28 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
433
477
  return res.json({ success: true });
434
478
  });
435
479
 
480
+ // ════════════════════════════════════════════════════════════════════════
481
+ // §4.5 停止当前这一轮
482
+ // ════════════════════════════════════════════════════════════════════════
483
+ //
484
+ // 为什么需要一条独立路由:点「停止」原先只做前端 `abortController.abort()`
485
+ // (useAgentChat.stop),服务端唯一的感知是 `req.socket` 的 close 事件。
486
+ // 走 dev 态的 vite 代理时那段 close 不一定传到后端,生产态浏览器也可能只是
487
+ // 停止读取而没有立刻关连接 —— 于是"用户已经点停止,服务端还在跑,闸一直占着,
488
+ // 再发就撞『该会话正在生成中』"。停止必须是**显式**的一条请求,不能靠连接语义猜。
489
+ //
490
+ // 回执语义:
491
+ // hit=false → 本来就没在跑,闸是空的,前端可以立刻重发
492
+ // hit=true → 已掐断,但**不立即放闸**(那一轮还在写会话快照)。宽限期内
493
+ // 它自己退出就由它的 finally 放;超时未退会被标记 abandoned 后
494
+ // 强制放闸(见 sessionGate.requestSessionAbort)。
495
+ app.post('/api/agent/abort', async (req, res) => {
496
+ const sessionId = String(req.body?.sessionId || '').trim();
497
+ if (!sessionId) return res.status(400).json({ success: false, error: '缺少 sessionId' });
498
+ const result = requestSessionAbort(sessionId);
499
+ return res.json({ success: true, sessionId, ...result });
500
+ });
501
+
436
502
  // ════════════════════════════════════════════════════════════════════════
437
503
  // §5. SSE 流式聊天(含工具调用循环)
438
504
  // ════════════════════════════════════════════════════════════════════════
@@ -449,6 +515,14 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
449
515
  // 两条一模一样的请求,还会把已经跑完的工具再跑一遍(见 agentChat.js 的 resume 参数)。
450
516
  const resume = req.body?.resume === true;
451
517
 
518
+ // 远端 peer 提问:peer 服务端环回调本路由,带 peer:true(见 peer/localAsk.js)。
519
+ //
520
+ // 这类对话**不属于任何项目** —— 是另一台机器发起的问题,用户在这台机器上根本没
521
+ // 为它选过项目。老版本按 hub 的当前项目(启动时的 process.cwd())给它落 cwd,
522
+ // 于是用户在本机的项目窗口里按项目过滤列表时永远看不到它("另一台电脑问完,
523
+ // 这台机器的会话列表没有新增"就是这么来的)。
524
+ const peerAsk = req.body?.peer === true;
525
+
452
526
  // 图片附件(base64 dataURL 数组):前端已限制只能选图片,这里再做一层白名单校验
453
527
  const images = (Array.isArray(req.body?.images) ? req.body.images : [])
454
528
  .filter(u => typeof u === 'string' && /^data:image\/[\w.+-]+;base64,/.test(u))
@@ -503,15 +577,59 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
503
577
  const abortController = new AbortController();
504
578
  let finished = false;
505
579
  let activeChild = null;
580
+ // 轮次内的检查点落盘(见下面 session 确定后的那段定义;这里先声明,
581
+ // 是为了让 finally 在任何退出路径上都能停掉定时器并做最后一次兜底写盘)
582
+ let checkpointTimer = null;
583
+ let flushSession = null;
584
+ let finalPersisted = false;
585
+
586
+ // 掐断这一轮的唯一动作。两个触发源共用它:
587
+ // · 客户端断开(socket close,见下面那段)
588
+ // · 用户点「停止」发来的 POST /api/agent/abort
589
+ // 第二条是 2026-11-03 补的 —— 只靠 socket close 太看运气(vite 代理下不一定
590
+ // 传得下来),停止必须是显式请求才靠得住。
591
+ // 子进程补一刀 SIGKILL:外部 CLI(claude/codex)常带孙进程,SIGTERM 未必理,
592
+ // 而这一轮卡在子进程上时,闸就跟着一起卡住。
593
+ const abortTurn = () => {
594
+ abortController.abort();
595
+ const child = activeChild;
596
+ if (!child) return;
597
+ try { child.kill('SIGTERM'); } catch {}
598
+ const killTimer = setTimeout(() => {
599
+ try { child.kill('SIGKILL'); } catch {}
600
+ }, 3000);
601
+ killTimer.unref?.();
602
+ };
506
603
 
507
604
  // 同一会话同一时刻只允许一个进行中的生成:跨视图(对话 Tab / 文件空间面板)
508
605
  // 或前端连点都靠这道闸挡住,否则两个流各自的会话快照会互相覆盖。
509
- if (sessionIdInput && isSessionBusy(sessionIdInput)) {
510
- send({ type: 'error', error: '该会话正在生成中,请等它结束后再发送' });
511
- finished = true;
512
- return res.end();
513
- }
606
+ //
607
+ // 2026-11-03:占用从"读完会话再 acquire"提前到这里,并带上 token 与 abort 通道。
608
+ // 原先是这里查一次、读完会话再 acquire,中间隔着 await readSession —— 两个并发
609
+ // 请求都能穿过检查,第二个 acquire 返回 false 还被忽略,于是两轮同跑、先结束的
610
+ // release 把闸提前放空。现在查与占是同一个原子调用,被占用时把占用时长回给前端,
611
+ // 界面才能说清"上一轮还在收尾(已 12s)"而不是笼统一句"正在生成中"。
514
612
  let activeSessionKey = null;
613
+ let acquiredTurn = null;
614
+ if (sessionIdInput) {
615
+ acquiredTurn = acquireSessionTurn(sessionIdInput, {
616
+ abort: abortTurn,
617
+ stage: 'preparing',
618
+ });
619
+ if (!acquiredTurn) {
620
+ const holder = getSessionTurn(sessionIdInput);
621
+ send({
622
+ type: 'error',
623
+ code: 'SESSION_BUSY',
624
+ error: '该会话正在生成中,请等它结束后再发送',
625
+ busyMs: holder ? Date.now() - holder.startedAt : null,
626
+ stage: holder?.stage || '',
627
+ });
628
+ finished = true;
629
+ return res.end();
630
+ }
631
+ activeSessionKey = sessionIdInput;
632
+ }
515
633
 
516
634
  // 心跳只在真的开跑这一轮之后起(上面那条 busy 短路不需要它,也不该背一个 interval)。
517
635
  // 出口统一在 finally 里 clearInterval —— 中间那些 return res.end() 全在 try 里,
@@ -523,12 +641,7 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
523
641
  // 客户端断开
524
642
  if (req.socket) {
525
643
  req.socket.once('close', () => {
526
- if (!finished) {
527
- abortController.abort();
528
- if (activeChild) {
529
- try { activeChild.kill('SIGTERM'); } catch {}
530
- }
531
- }
644
+ if (!finished) abortTurn();
532
645
  });
533
646
  }
534
647
 
@@ -557,7 +670,21 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
557
670
  }
558
671
  throw err;
559
672
  }
560
- if (session.cwd && normalizeCwd(session.cwd) !== normalizeCwd(currentProjectCwd)) {
673
+ if (peerAsk) {
674
+ // 存量远端会话当时被写成了"hub 的项目":这里顺手迁成项目中立,否则它在
675
+ // 任何一个项目窗口里都被过滤掉,等于永远打不开。
676
+ //
677
+ // 就地落盘(不等本轮结束):这一轮后面还可能因为没配模型 / 断网直接收尾,
678
+ // 那些路径不会写 session,迁移就得再等一次成功的轮次 —— 而在此之前它仍然
679
+ // 被项目过滤掉,用户也就没机会让它成功跑一轮。
680
+ if (session.cwd || session.source !== 'peer') {
681
+ session.cwd = '';
682
+ session.source = 'peer';
683
+ await writeSession(session.sessionId, session).catch((err) => {
684
+ logger.warn(`[agent] 远端会话迁移到项目中立失败(本轮继续): ${err?.message || err}`);
685
+ });
686
+ }
687
+ } else if (session.cwd && normalizeCwd(session.cwd) !== normalizeCwd(currentProjectCwd)) {
561
688
  send({ type: 'error', error: '该会话属于其他项目,无法在当前项目中继续', code: 'PROJECT_MISMATCH' });
562
689
  finished = true;
563
690
  return res.end();
@@ -588,8 +715,10 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
588
715
  version: 1,
589
716
  sessionId: genSessionId(),
590
717
  title: '(新会话)',
591
- source: 'web',
592
- cwd: currentProjectCwd,
718
+ // 远端提问单独标 source:列表里能一眼认出"这条不是我在这台机器上开的",
719
+ // 也是它不归属任何项目的显式标记(cwd 留空)。
720
+ source: peerAsk ? 'peer' : 'web',
721
+ cwd: peerAsk ? '' : currentProjectCwd,
593
722
  model: '',
594
723
  // 引擎:gai(内置,默认)| claude | opencode | codex | codebuddy | pi
595
724
  engine: normalizeAgentEngine(req.body?.engine),
@@ -604,9 +733,73 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
604
733
  isNew = true;
605
734
  }
606
735
 
607
- // 本轮占用该会话(finally 里释放)
608
- activeSessionKey = session.sessionId;
609
- acquireSession(activeSessionKey);
736
+ // 本轮占用该会话(finally 里释放)。
737
+ // 已有会话在请求入口就占了(上面那段);这里只补"新建会话"的情况 ——
738
+ // sessionId 是刚生成的,入口时还不存在,没法提前占。
739
+ if (!acquiredTurn) {
740
+ acquiredTurn = acquireSessionTurn(session.sessionId, {
741
+ abort: abortTurn,
742
+ stage: 'preparing',
743
+ });
744
+ if (!acquiredTurn) {
745
+ send({ type: 'error', code: 'SESSION_BUSY', error: '该会话正在生成中,请等它结束后再发送' });
746
+ finished = true;
747
+ return res.end();
748
+ }
749
+ activeSessionKey = session.sessionId;
750
+ }
751
+
752
+ // ── 轮次内检查点落盘 ──────────────────────────────────────
753
+ //
754
+ // 原先整轮只在最后写一次盘,代价是:跑到一半后端重启 / 进程崩掉,这一轮的
755
+ // 内容全丢,磁盘上甚至没有这个会话(左栏随之留下一条点开就 404 的幽灵,
756
+ // 见上面预落盘那段)。用户的原话是"这个不能随时写入么"——能,但不能按
757
+ // 增量写:一轮几千个 token,等于把磁盘当缓冲区用。
758
+ //
759
+ // 折中是按 CHECKPOINT_MS 节流:崩了最多丢这两秒,正常一轮多几十次原子写
760
+ // (一次 100~200KB,SSD 上几毫秒)。想看实时进度请走 SSE,这里只负责"别丢"。
761
+ //
762
+ // 写必须串行:同一份文件两次 rename 交错时,慢的那次会把新内容盖回旧的。
763
+ // 用一个单槽队列:正在写的时候又要求写,就合并进"下一份"(写的时候取的是
764
+ // session 引用,序列化发生在真正落笔那一刻,天然是最新内容)。
765
+ // 积压上限 1 —— 磁盘卡住时不能让队列无限长。
766
+ let persistChain = Promise.resolve();
767
+ let persistBacklog = 0;
768
+ let lastPersistError = null;
769
+ // force=true 用于"这一轮结束了,必须把最终版写下去":此时不能被合并掉,
770
+ // 否则在写队列正忙时可能只写进去一份更早的快照,最终内容反而丢了。
771
+ flushSession = (opts = {}) => {
772
+ if (!opts.force && persistBacklog >= 1) return persistChain;
773
+ persistBacklog += 1;
774
+ persistChain = persistChain.then(async () => {
775
+ try {
776
+ session.updatedAt = nowIso();
777
+ await writeSession(session.sessionId, session);
778
+ lastPersistError = null;
779
+ } catch (err) {
780
+ // 由下面的定时器统一记一条,避免磁盘异常时每 2 秒刷一行日志
781
+ lastPersistError = err;
782
+ } finally {
783
+ persistBacklog -= 1;
784
+ }
785
+ });
786
+ return persistChain;
787
+ };
788
+
789
+ // 脏检查只看"条数 + 末条长度":工具循环里正文增量只动最后这一条,
790
+ // 每 2 秒算一次这两个数的代价可以忽略,比整份 JSON.stringify 便宜得多。
791
+ let checkpointSig = '';
792
+ checkpointTimer = setInterval(() => {
793
+ if (lastPersistError) {
794
+ logger.warn(`[agent] 检查点落盘失败(本轮继续): ${lastPersistError?.message || lastPersistError}`);
795
+ lastPersistError = null;
796
+ }
797
+ const last = session.messages[session.messages.length - 1];
798
+ const sig = `${session.messages.length}:${typeof last?.content === 'string' ? last.content.length : 0}`;
799
+ if (sig === checkpointSig) return;
800
+ checkpointSig = sig;
801
+ flushSession();
802
+ }, CHECKPOINT_MS);
610
803
 
611
804
  // 附件落盘(拿到绝对路径后再把路径交给模型)。落盘失败不该拖垮整轮对话:
612
805
  // 拿不到路径最多是模型看不到附件,用户仍能正常对话。
@@ -709,6 +902,25 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
709
902
  title: isNew ? autoTitle([{ role: 'user', content: userMessage }]) : session.title
710
903
  });
711
904
 
905
+ // 新会话先落一次盘(草稿)。
906
+ //
907
+ // 写盘原先只发生在轮次结束(见下面 writeSession 那段),于是第一轮跑到一半
908
+ // 后端重启 / 进程崩掉时,这条会话在磁盘上从来没存在过 —— 而前端左栏已经按
909
+ // meta 把它显示了(还会因为「点过停止」把它记进 pendingPersistIds 一直保留),
910
+ // 结果就是一条点开必报「会话不存在」的幽灵条目。
911
+ // 2026-11-03 实测:ag-mvzh17c0-kb8ihnqm 在 hub.log 里连刷四次 404。
912
+ // 这里先落一份空壳,最坏情况是"打开只有这条 user 消息",至少不是一个死链。
913
+ // 失败不拖垮本轮:这一轮照常跑,最坏情况退回原来的行为。
914
+ if (isNew) {
915
+ try {
916
+ session.title = autoTitle([{ role: 'user', content: userMessage }]);
917
+ session.updatedAt = nowIso();
918
+ await writeSession(session.sessionId, session);
919
+ } catch (err) {
920
+ logger.warn(`[agent] 新会话预落盘失败(本轮继续): ${err?.message || err}`);
921
+ }
922
+ }
923
+
712
924
  // dispatch_task 的注入闸门:**只有右下角 g ai 悬浮面板(AgentView)发起的对话**
713
925
  // 才有派发能力(前端在请求体里带 allowDispatch: true)。
714
926
  //
@@ -856,8 +1068,19 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
856
1068
 
857
1069
  // 持久化。中止(用户点"停止")的轮次同样要落盘 —— 磁盘上没有这条会话时,
858
1070
  // 前端停止后立刻刷新列表会把左栏这一条整个吞掉(表现为"一停止任务就没了")。
859
- session.updatedAt = nowIso();
860
- await writeSession(session.sessionId, session);
1071
+ //
1072
+ // 例外:被宽限兜底放弃的轮次不落盘。那种情况说明点了停止之后这一轮 3 秒都没
1073
+ // 退出(卡在不响应 signal 的 await 上),闸已经被强制放给新一轮了 —— 这里再写
1074
+ // 一次只会把新一轮的快照覆盖掉。它也没有读者:前端流早断了,内存里那份 session
1075
+ // 对象随本轮结束一起消失。
1076
+ if (acquiredTurn?.abandoned) {
1077
+ logger.warn(`[agent] 轮次停止超时未退出,已放弃并跳过落盘: ${session.sessionId}`);
1078
+ } else {
1079
+ // 走同一个串行队列:检查点可能还在写,这里排队等它写完再落最终版,
1080
+ // 否则两次 rename 交错会把最终内容盖回旧的。
1081
+ await flushSession({ force: true });
1082
+ finalPersisted = true;
1083
+ }
861
1084
  enforceRetention().catch(() => {});
862
1085
 
863
1086
  finished = true;
@@ -868,7 +1091,16 @@ export function registerAgentRoutes({ app, getCurrentProjectPath, configManager,
868
1091
  res.end();
869
1092
  } finally {
870
1093
  clearInterval(heartbeat);
871
- if (activeSessionKey) releaseSession(activeSessionKey);
1094
+ if (checkpointTimer) clearInterval(checkpointTimer);
1095
+ // 异常 / 中止路径也保住内容:原先 catch 里完全没有 writeSession,
1096
+ // 一轮跑到一半出错(模型报错、工具崩、外部 CLI 退出)就等于整轮蒸发。
1097
+ // 正常路径已经写过,用 finalPersisted 跳过,避免同一份内容写两遍。
1098
+ if (flushSession && !finalPersisted && !acquiredTurn?.abandoned) {
1099
+ await flushSession({ force: true });
1100
+ }
1101
+ // 带 token 释放:闸可能已被宽限兜底放给了新一轮,那一次释放必须无效,
1102
+ // 否则会把别人的占用一起删掉(原先是无主 release,正好漏这一下)。
1103
+ if (activeSessionKey) releaseSession(activeSessionKey, acquiredTurn?.token);
872
1104
  }
873
1105
  });
874
1106
 
@@ -0,0 +1,114 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ // 「会话目录变了」的进程内广播。
16
+ //
17
+ // 为什么需要它:左栏的会话列表只在**本窗口自己发起的动作**之后刷新(一轮流结束、
18
+ // 切项目、定时任务跑完),可会话文件是整机共享的 —— 远端 peer 提问、另一个 g ui
19
+ // 窗口、CLI 都会往同一个目录里写。没有这条信号时,用户在另一台机器问完话,回到
20
+ // 本机只能靠切项目 / 刷新页面才看得见那条新会话(2026-11-04 用户报的就是这个)。
21
+ //
22
+ // 为什么用 fs.watch 而不是让 peer 层发事件:写盘方可能根本不在本进程(hub / 项目
23
+ // 窗口 / CLI),只有「目录变了」这件事是所有写盘路径的共同事实。也正因为如此,
24
+ // 它顺带覆盖了定时任务、外部 CLI、多窗口这些原本各自要单独接线的场景。
25
+ //
26
+ // 目录里一次保存是 tmp + rename 两步,一轮对话还会中途检查点落盘 —— 必须 debounce,
27
+ // 否则前端会被连续几帧「列表变了」打爆。
28
+
29
+ import fs from 'node:fs';
30
+
31
+ import { AGENT_SESSIONS_DIR } from '../../../../paths.js';
32
+ import logger from '../../utils/logger.js';
33
+
34
+ /** 合并同一批写入的窗口:一次落盘会产生好几个目录事件。 */
35
+ const DEBOUNCE_MS = 500;
36
+
37
+ const listeners = new Set();
38
+ /** @type {fs.FSWatcher|null} */
39
+ let watcher = null;
40
+ let watchedDir = '';
41
+ /** @type {NodeJS.Timeout|null} */
42
+ let timer = null;
43
+
44
+ function publish() {
45
+ for (const cb of [...listeners]) {
46
+ try {
47
+ cb();
48
+ } catch (err) {
49
+ logger.warn(`[agent-sessions] 变更通知回调失败: ${err?.message || err}`);
50
+ }
51
+ }
52
+ }
53
+
54
+ function schedule() {
55
+ if (timer) return;
56
+ timer = setTimeout(() => {
57
+ timer = null;
58
+ publish();
59
+ }, DEBOUNCE_MS);
60
+ timer.unref?.();
61
+ }
62
+
63
+ function stopWatcher() {
64
+ if (timer) {
65
+ clearTimeout(timer);
66
+ timer = null;
67
+ }
68
+ if (!watcher) return;
69
+ try { watcher.close(); } catch {}
70
+ watcher = null;
71
+ watchedDir = '';
72
+ }
73
+
74
+ function ensureWatcher(dir) {
75
+ if (watcher && watchedDir === dir) return;
76
+ stopWatcher();
77
+ try {
78
+ // 首次启动时目录可能还没建(一条会话都没跑过)—— 不建就 watch 会 ENOENT。
79
+ fs.mkdirSync(dir, { recursive: true });
80
+ watcher = fs.watch(dir, { persistent: false }, () => schedule());
81
+ // 目录被删 / 权限变化:关掉,等下次有人订阅时重建。留一个死 watcher 只会静默漏事件。
82
+ watcher.on('error', (err) => {
83
+ logger.warn(`[agent-sessions] 目录监听中断: ${err?.message || err}`);
84
+ stopWatcher();
85
+ });
86
+ watchedDir = dir;
87
+ } catch (err) {
88
+ stopWatcher();
89
+ logger.warn(`[agent-sessions] 无法监听会话目录: ${err?.message || err}`);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 订阅会话目录变更。返回取消订阅函数。
95
+ *
96
+ * 最后一个订阅者离开时关掉 watcher —— 没人看列表的窗口不该常驻一个目录监听。
97
+ *
98
+ * @param {() => void} cb
99
+ * @param {string} [dir] 会话目录(单测注入临时目录,别碰真实数据目录)
100
+ */
101
+ export function subscribeAgentSessionChanges(cb, dir = AGENT_SESSIONS_DIR) {
102
+ if (typeof cb !== 'function') return () => {};
103
+ listeners.add(cb);
104
+ ensureWatcher(dir);
105
+ return () => {
106
+ listeners.delete(cb);
107
+ if (listeners.size === 0) stopWatcher();
108
+ };
109
+ }
110
+
111
+ /** 当前是否在监听(排查用)。 */
112
+ export function isAgentSessionWatchActive() {
113
+ return !!watcher;
114
+ }
@@ -0,0 +1,56 @@
1
+ // 会话目录监听的回归测试。
2
+ //
3
+ // 这条信号承载的是"别的进程刚写了会话"—— 远端 peer 提问、另一个 g ui 窗口、CLI。
4
+ // 没有它时左栏永远停在打开页面时那份(用户 2026-11-04 报的就是这个)。
5
+ //
6
+ // 夹具用临时目录(绝不能落到用户真实的 <DATA_DIR>/agent-sessions)。
7
+
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ import { subscribeAgentSessionChanges, isAgentSessionWatchActive } from './agentSessionEvents.js';
15
+
16
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
+
18
+ test('会话目录里落盘会触发通知,且同一批写入被 debounce 合并', async () => {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'zen-agent-sessions-'));
20
+ let dispose = () => {};
21
+ try {
22
+ let hits = 0;
23
+ dispose = subscribeAgentSessionChanges(() => { hits += 1; }, dir);
24
+ assert.equal(isAgentSessionWatchActive(), true);
25
+
26
+ // 一次保存是 tmp + rename 两步,这里再叠三份模拟"连写"。
27
+ for (let i = 0; i < 3; i += 1) {
28
+ const tmp = path.join(dir, `s${i}.json.tmp`);
29
+ fs.writeFileSync(tmp, '{}');
30
+ fs.renameSync(tmp, path.join(dir, `s${i}.json`));
31
+ }
32
+ await sleep(1200);
33
+
34
+ assert.ok(hits >= 1, '目录变了却没有通知 = 前端永远不会自动刷新');
35
+ assert.ok(hits <= 3, `同一批写入应被合并,实际 ${hits} 次`);
36
+ } finally {
37
+ // 必须在删目录前断开:监听残留会污染下一条用例的"订阅者计数"。
38
+ dispose();
39
+ fs.rmSync(dir, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ test('最后一个订阅者离开后关掉监听(没人看列表的窗口不该常驻 watcher)', () => {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'zen-agent-sessions-'));
45
+ try {
46
+ const disposeA = subscribeAgentSessionChanges(() => {}, dir);
47
+ const disposeB = subscribeAgentSessionChanges(() => {}, dir);
48
+ assert.equal(isAgentSessionWatchActive(), true);
49
+ disposeA();
50
+ assert.equal(isAgentSessionWatchActive(), true, '还有订阅者时不能关');
51
+ disposeB();
52
+ assert.equal(isAgentSessionWatchActive(), false);
53
+ } finally {
54
+ fs.rmSync(dir, { recursive: true, force: true });
55
+ }
56
+ });