@xcanwin/manyoyo 7.0.18 → 7.0.21

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.
package/lib/web/server.js CHANGED
@@ -30,82 +30,7 @@ const {
30
30
  projectSessionEvents
31
31
  } = require('../core/events');
32
32
  const { FileEventStore } = require('../core/event-store');
33
- const { buildManyoyoLogPath, getLocalDateTag } = require('../log-path'); // DIAG_LOG
34
33
 
35
- // DIAG_LOG BEGIN:临时详细诊断日志基础设施,用于排查 serve 接口超时 / agent
36
- // 流式回复长时间卡在"…"不实时更新的问题。排查结束后可整体删除:全局搜索
37
- // "DIAG_LOG" 能定位到全部相关代码(这段辅助函数、各处打点调用、下方
38
- // /api/diag/logs 接口、/logs 页面路由),一并删掉不影响其它功能。
39
- const DIAG_LOG_LINE_PATTERN = /^\[([^\]]+)\] \[pid:(\d+)\] \[(\w+)\] \[DIAG_LOG\] (\S+)(?: ([\s\S]*))?$/;
40
-
41
- function diagLog(ctx, category, extra = {}) {
42
- if (!ctx || !ctx.logger || typeof ctx.logger.info !== 'function') {
43
- return;
44
- }
45
- ctx.logger.info(`[DIAG_LOG] ${String(category || '')}`, extra);
46
- }
47
-
48
- function parseDiagLogLine(line) {
49
- const matched = String(line || '').match(DIAG_LOG_LINE_PATTERN);
50
- if (!matched) {
51
- return null;
52
- }
53
- let extra = {};
54
- if (matched[5]) {
55
- try {
56
- extra = JSON.parse(matched[5]);
57
- } catch (e) {
58
- extra = { raw: matched[5] };
59
- }
60
- }
61
- return {
62
- ts: matched[1],
63
- pid: Number(matched[2]),
64
- level: matched[3],
65
- category: matched[4],
66
- extra
67
- };
68
- }
69
-
70
- function diagLogDir(ctx) {
71
- if (ctx && ctx.logger && typeof ctx.logger.path === 'string' && ctx.logger.path) {
72
- return path.dirname(ctx.logger.path);
73
- }
74
- return buildManyoyoLogPath('serve').dir;
75
- }
76
-
77
- function readDiagLogEntries(ctx, options = {}) {
78
- const dateTag = options.date ? String(options.date) : getLocalDateTag();
79
- const filePath = path.join(diagLogDir(ctx), `serve-${dateTag}.log`);
80
- if (!fs.existsSync(filePath)) {
81
- return [];
82
- }
83
- const limit = Number.isInteger(options.limit) && options.limit > 0 ? Math.min(options.limit, 2000) : 500;
84
- const category = options.category ? String(options.category) : '';
85
- const session = options.session ? String(options.session) : '';
86
- const keyword = options.keyword ? String(options.keyword).toLowerCase() : '';
87
-
88
- const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
89
- const entries = [];
90
- for (const line of lines) {
91
- const entry = parseDiagLogLine(line);
92
- if (!entry) {
93
- continue;
94
- }
95
- if (category && entry.category !== category) {
96
- continue;
97
- }
98
- if (session && entry.extra.session !== session) {
99
- continue;
100
- }
101
- if (keyword && !line.toLowerCase().includes(keyword)) {
102
- continue;
103
- }
104
- entries.push(entry);
105
- }
106
- return entries.slice(-limit);
107
- }
108
- // DIAG_LOG END(下方还有零散打点调用与 /api/diag/logs、/logs 路由,同样标记为 DIAG_LOG)
109
34
 
110
35
  const WEB_HISTORY_MAX_MESSAGES = 500;
111
36
  const WEB_OUTPUT_MAX_CHARS = 16000;
@@ -138,6 +63,11 @@ const WEB_SESSION_KEY_SEPARATOR = '~';
138
63
  const WEB_DEFAULT_AGENT_ID = 'default';
139
64
  const WEB_DEFAULT_AGENT_NAME = 'AGENT 1';
140
65
  const WEB_CONFIG_KEEP_SECRET_PLACEHOLDER = '***HIDDEN_SECRET***';
66
+ // 取 15s 是为了在 nginx proxy_read_timeout 默认 60s 下留足余量(即便某次心跳
67
+ // 因事件循环繁忙被推迟,也还有三次机会),同时不至于把 NDJSON 流刷得太碎
68
+ const DEFAULT_AGENT_STREAM_HEARTBEAT_MS = 15 * 1000;
69
+ // token 级增量的历史落盘节流:写盘只为断线/刷新后能对账恢复,不需要逐 token 持久化
70
+ const AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS = 1000;
141
71
  const FRONTEND_DIR = path.join(__dirname, 'frontend');
142
72
  const SAFE_CONTAINER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
143
73
  const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)$/;
@@ -462,9 +392,10 @@ function removeContainerIdempotent(ctx, containerName) {
462
392
  }
463
393
  }
464
394
 
465
- function removeAllAgentHistoryArtifacts(state, containerName, agentIds) {
395
+ function removeAllAgentHistoryArtifacts(webHistoryDir, containerName, agentIds) {
396
+ const eventStore = new FileEventStore(webHistoryDir);
466
397
  (Array.isArray(agentIds) ? agentIds : []).forEach(agentId => {
467
- state.eventStore.remove(buildWebSessionKey(containerName, agentId));
398
+ eventStore.remove(buildWebSessionKey(containerName, agentId));
468
399
  });
469
400
  }
470
401
 
@@ -622,57 +553,29 @@ function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role,
622
553
  return message;
623
554
  }
624
555
 
625
- // 流式过程中高频出现的"透传"控制事件类型(见 resolveWebStreamControlEventType):
626
- // 每个 content_delta/trace 事件都会各自触发一次,量随回合长度线性增长。会话
627
- // 生命周期事件(session.ready/stopping/process.exited 等)不在其中,必须每次
628
- // 都立即落盘。
629
- const NOISY_CONTROL_EVENT_TYPES = new Set(['process.stdout', 'agent.turn.delta', 'agent.tool.observed']);
630
- const LIVE_HISTORY_FLUSH_INTERVAL_MS = 400;
631
-
632
- // 中间态历史落盘节流:同一 aggregateId 在节流窗口内只允许成功落盘一次,供
633
- // appendWebSessionControlEvent(events 镜像)与 /agent/stream 路由里的
634
- // patchWebSessionMessage(trace/content_delta 消息内容)共用同一个"落盘配额",
635
- // 避免同一个事件被两条独立写路径各自整份重写一次 demo.json。节流只影响"实时
636
- // 进度对轮询标签页可见的时机",不影响最终数据——两条写路径在回合结束/出错时
637
- // 都还有一次不受节流影响的完整落盘。
638
- function shouldFlushLiveHistory(state, aggregateId) {
639
- const now = Date.now();
640
- const lastFlushAt = state._liveHistoryFlushThrottle.get(aggregateId) || 0;
641
- if (now - lastFlushAt < LIVE_HISTORY_FLUSH_INTERVAL_MS) {
642
- return false;
643
- }
644
- state._liveHistoryFlushThrottle.set(aggregateId, now);
645
- return true;
646
- }
647
-
648
- function appendWebSessionControlEvent(state, sessionRef, type, data = {}) {
649
- const aggregateId = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
650
- // seq 必须以 state.eventStore 的内存缓存为准(而不是下面 agentSession.events
651
- // 这份可能因落盘节流而滞后的历史 JSON 副本),否则节流生效时算出来的 seq
652
- // 会与 eventStore 实际的 lastSeq 对不上,append() 会因"seq 必须连续递增"报错。
556
+ function appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data = {}) {
557
+ const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
558
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
559
+ const events = Array.isArray(agentSession.events) ? agentSession.events : [];
560
+ const lastEvent = events.length ? events[events.length - 1] : null;
653
561
  const event = createControlEvent({
654
562
  type,
655
- aggregateId,
656
- seq: state.eventStore.getNextSeq(aggregateId),
563
+ aggregateId: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
564
+ seq: lastEvent ? lastEvent.seq + 1 : 1,
657
565
  data
658
566
  });
659
- state.eventStore.append(event);
660
-
661
- const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
662
- const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
663
- const events = Array.isArray(agentSession.events) ? agentSession.events : [];
567
+ const eventStore = new FileEventStore(webHistoryDir);
568
+ eventStore.append(event);
664
569
  agentSession.events = [...events, event].slice(-WEB_HISTORY_MAX_MESSAGES);
665
570
  agentSession.updatedAt = event.timestamp;
666
571
  history.updatedAt = event.timestamp;
667
- if (!NOISY_CONTROL_EVENT_TYPES.has(type) || shouldFlushLiveHistory(state, aggregateId)) {
668
- saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
669
- }
572
+ saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
670
573
  return event;
671
574
  }
672
575
 
673
- function loadWebSessionControlEvents(state, sessionRef, fallbackEvents = []) {
576
+ function loadWebSessionControlEvents(webHistoryDir, sessionRef, fallbackEvents = []) {
674
577
  try {
675
- const events = state.eventStore.read(
578
+ const events = new FileEventStore(webHistoryDir).read(
676
579
  buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)
677
580
  );
678
581
  if (events.length) {
@@ -957,7 +860,12 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
957
860
  const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
958
861
  const flagSpecs = [
959
862
  { flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
960
- { flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ }
863
+ { flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ },
864
+ // 没有这个 flag 时 claude 只在整条 assistant 消息生成完之后才吐一行 JSON:
865
+ // 一段几千字的回答会静默几十秒再一次性刷出来,用户看到的"执行过程"是分批跳变的。
866
+ // 带上之后会额外输出 stream_event/content_block_delta 的 token 级增量
867
+ //(解析见 extractContentDeltaFromPayload)
868
+ { flag: '--include-partial-messages', pattern: /(?:^|\s)--include-partial-messages(?:\s|$)/ }
961
869
  ];
962
870
  const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
963
871
  if (sessionId) {
@@ -1819,12 +1727,39 @@ function prepareStructuredTraceEvents(agentProgram, payload, state) {
1819
1727
  return [];
1820
1728
  }
1821
1729
 
1822
- function extractContentDeltaFromPayload(agentProgram, payload) {
1730
+ function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
1823
1731
  if (!payload || typeof payload !== 'object') {
1824
1732
  return null;
1825
1733
  }
1826
1734
  if (agentProgram === 'claude') {
1827
- if (pickFirstString(payload.type) !== 'assistant') {
1735
+ const payloadType = pickFirstString(payload.type);
1736
+ // --include-partial-messages 产生的 token 级增量。text 是累计全文(服务端
1737
+ // 落盘用),chunk 是本次新增的片段(下发给前端用)——一轮回答可能有几千个
1738
+ // 增量,每次都把累计全文发一遍是 O(n²) 流量,长回答能到 MB 级。
1739
+ // 一轮里可能有多条 assistant 消息(正文→工具→正文),message_start 清空累计,
1740
+ // 语义与下面整条 assistant 消息的 reset 一致
1741
+ if (payloadType === 'stream_event') {
1742
+ const streamEvent = toPlainObject(payload.event);
1743
+ const streamEventType = pickFirstString(streamEvent.type);
1744
+ if (streamEventType === 'message_start') {
1745
+ state.claudePartialText = '';
1746
+ return { text: '', reset: true, partial: true, chunk: '', chunkReset: true };
1747
+ }
1748
+ if (streamEventType !== 'content_block_delta') {
1749
+ return null;
1750
+ }
1751
+ const delta = toPlainObject(streamEvent.delta);
1752
+ if (pickFirstString(delta.type) !== 'text_delta') {
1753
+ return null;
1754
+ }
1755
+ const deltaText = typeof delta.text === 'string' ? delta.text : '';
1756
+ if (!deltaText) {
1757
+ return null;
1758
+ }
1759
+ state.claudePartialText = `${state.claudePartialText || ''}${deltaText}`;
1760
+ return { text: state.claudePartialText, reset: true, partial: true, chunk: deltaText };
1761
+ }
1762
+ if (payloadType !== 'assistant') {
1828
1763
  return null;
1829
1764
  }
1830
1765
  const message = toPlainObject(payload.message);
@@ -2096,7 +2031,7 @@ function prepareCodexTraceEvent(payload) {
2096
2031
  async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2097
2032
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2098
2033
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
2099
- const containerMap = listWebManyoyoContainers(ctx, state);
2034
+ const containerMap = listWebManyoyoContainers(ctx);
2100
2035
  const containerInfo = containerMap[sessionRef.containerName] || {};
2101
2036
  const normalizedContainerTemplate = normalizeAgentPromptCommandTemplate(history.agentPromptCommand, 'agentPromptCommand');
2102
2037
  if (normalizedContainerTemplate !== history.agentPromptCommand) {
@@ -2745,11 +2680,26 @@ function parseAndValidateConfigRaw(raw) {
2745
2680
  return config;
2746
2681
  }
2747
2682
 
2683
+ // 新建容器对话框会拿 defaults.hostPath 做预填,但 validateHostPath 明确拒绝
2684
+ // 根目录 / /home / $HOME。serve 以 $HOME 启动(root 用户下就是 /root)时,
2685
+ // 预填的值必定创建失败——这里直接留空,逼用户用"选择"选一个真实工作目录
2686
+ function sanitizeDefaultHostPath(hostPath) {
2687
+ const value = String(hostPath || '').trim();
2688
+ if (!value) {
2689
+ return '';
2690
+ }
2691
+ const homeDir = process.env.HOME || os.homedir() || '/home';
2692
+ if (value === '/' || value === '/home' || value === homeDir) {
2693
+ return '';
2694
+ }
2695
+ return value;
2696
+ }
2697
+
2748
2698
  function buildConfigDefaults(ctx, config) {
2749
2699
  const parsed = toPlainObject(config);
2750
2700
  const defaults = {
2751
2701
  containerName: hasOwn(parsed, 'containerName') ? String(parsed.containerName || '') : '',
2752
- hostPath: pickFirstString(parsed.hostPath, ctx.hostPath),
2702
+ hostPath: sanitizeDefaultHostPath(pickFirstString(parsed.hostPath, ctx.hostPath)),
2753
2703
  containerPath: pickFirstString(parsed.containerPath, ctx.containerPath),
2754
2704
  imageName: pickFirstString(parsed.imageName, ctx.imageName),
2755
2705
  imageVersion: pickFirstString(parsed.imageVersion, ctx.imageVersion),
@@ -3036,7 +2986,7 @@ function buildCreateRuntime(ctx, state, payload) {
3036
2986
  }
3037
2987
 
3038
2988
  function resolveUniqueContainerName(ctx, state, baseName) {
3039
- const containerMap = listWebManyoyoContainers(ctx, state);
2989
+ const containerMap = listWebManyoyoContainers(ctx);
3040
2990
  const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
3041
2991
  const taken = new Set([...Object.keys(containerMap), ...historyNames]);
3042
2992
  // 基于历史最大编号 +1 命名,即使较小编号的副本被删除也不回收其编号,
@@ -3117,7 +3067,7 @@ async function createClonedContainer(ctx, state, sourceContainerName, requestedN
3117
3067
  let finalName = '';
3118
3068
  if (requestedName) {
3119
3069
  validateContainerNameStrict(requestedName);
3120
- const containerMap = listWebManyoyoContainers(ctx, state);
3070
+ const containerMap = listWebManyoyoContainers(ctx);
3121
3071
  const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
3122
3072
  const taken = new Set([...Object.keys(containerMap), ...historyNames]);
3123
3073
  if (taken.has(requestedName)) {
@@ -3181,20 +3131,7 @@ function estimateStartTimeFromStatus(status) {
3181
3131
  return null;
3182
3132
  }
3183
3133
 
3184
- // 同一进程内短 TTL 缓存:GET /api/sessions、/detail 等接口各自独立调用这个函数,
3185
- // 容器一多时 docker ps + 批量 inspect 本身就不便宜,多标签页轮询会让它们在
3186
- // 短时间内反复触发。2 秒内的重复调用直接复用上一次结果,不再次触碰
3187
- // ctx.dockerExecArgs(同步 spawnSync,会独占事件循环)。
3188
- const CONTAINER_LIST_CACHE_TTL_MS = 2000;
3189
-
3190
- function listWebManyoyoContainers(ctx, state) {
3191
- if (state && state._containerListCache) {
3192
- const { timestamp, data } = state._containerListCache;
3193
- if (Date.now() - timestamp < CONTAINER_LIST_CACHE_TTL_MS) {
3194
- return data;
3195
- }
3196
- }
3197
-
3134
+ function listWebManyoyoContainers(ctx) {
3198
3135
  const output = ctx.dockerExecArgs(
3199
3136
  ['ps', '-a', '--format', '{{.Names}}\t{{.Status}}\t{{.Image}}'],
3200
3137
  { ignoreError: true }
@@ -3202,9 +3139,6 @@ function listWebManyoyoContainers(ctx, state) {
3202
3139
 
3203
3140
  const map = {};
3204
3141
  if (!output.trim()) {
3205
- if (state) {
3206
- state._containerListCache = { timestamp: Date.now(), data: map };
3207
- }
3208
3142
  return map;
3209
3143
  }
3210
3144
 
@@ -3222,8 +3156,11 @@ function listWebManyoyoContainers(ctx, state) {
3222
3156
  });
3223
3157
 
3224
3158
  // 逐容器单独 docker inspect 是 N+1 同步阻塞调用(spawnSync 无 timeout,会
3225
- // 独占 Node 单线程事件循环);容器一多,serve 所有接口(包括正在流式输出
3226
- // 的 agent/stream)都会被拖慢甚至冻结。这里合并成一次批量 inspect。
3159
+ // 独占 Node 单线程事件循环),这里合并成一次批量 inspect。
3160
+ // 注意:31 个容器实测 `ps -a` 约 40ms、批量 `inspect` 约 45ms,容器运行时调用
3161
+ // 并不是 GET /api/sessions 慢的原因——真正的开销在逐容器同步读取并解析整份
3162
+ // 历史 JSON(见 buildSessionSummary 的 preloadedHistory 注释)。排查这条路径
3163
+ // 变慢时别先怀疑 docker/podman。
3227
3164
  const defaultCommandByName = {};
3228
3165
  if (candidates.length) {
3229
3166
  let inspectOutput = '';
@@ -3261,9 +3198,6 @@ function listWebManyoyoContainers(ctx, state) {
3261
3198
  };
3262
3199
  });
3263
3200
 
3264
- if (state) {
3265
- state._containerListCache = { timestamp: Date.now(), data: map };
3266
- }
3267
3201
  return map;
3268
3202
  }
3269
3203
 
@@ -3721,24 +3655,38 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
3721
3655
  const sessionRef = typeof sessionRefOrContainerName === 'string'
3722
3656
  ? { containerName: sessionRefOrContainerName, agentId: WEB_DEFAULT_AGENT_ID }
3723
3657
  : sessionRefOrContainerName;
3724
- const sessionKey = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
3725
3658
  const agentProgram = typeof opts.agentProgram === 'string' ? opts.agentProgram : '';
3726
3659
  const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : () => {};
3727
- const process = spawn(
3728
- ctx.dockerCmd,
3729
- ['exec', sessionRef.containerName, '/bin/bash', '-lc', command],
3730
- { stdio: ['ignore', 'pipe', 'pipe'] }
3731
- );
3660
+ // 调用方可能已经在路由入口占过锁(见 reserveWebAgentRun):复用那一份 runState,
3661
+ // 不要另起一个对象,否则 stopWebAgentRun 拿到的还是占位、杀不到真正的进程
3662
+ const runState = opts.runState && typeof opts.runState === 'object'
3663
+ ? opts.runState
3664
+ : reserveWebAgentRun(state, sessionRef);
3665
+ let process;
3666
+ try {
3667
+ process = spawn(
3668
+ ctx.dockerCmd,
3669
+ ['exec', sessionRef.containerName, '/bin/bash', '-lc', command],
3670
+ { stdio: ['ignore', 'pipe', 'pipe'] }
3671
+ );
3672
+ } catch (e) {
3673
+ // spawn 同步抛错(如 dockerCmd 不存在)时必须释放锁,否则这个容器永远发不出消息
3674
+ releaseWebAgentRun(state, sessionRef.containerName, runState);
3675
+ throw e;
3676
+ }
3732
3677
 
3733
- const runState = {
3734
- containerName: sessionRef.containerName,
3735
- sessionKey,
3736
- process,
3737
- command,
3738
- startedAt: new Date().toISOString(),
3739
- stopping: false
3740
- };
3678
+ runState.process = process;
3679
+ runState.command = command;
3680
+ runState.startedAt = new Date().toISOString();
3741
3681
  state.agentRuns.set(sessionRef.containerName, runState);
3682
+ // 准备阶段(拉起容器等)用户就点了停止:进程刚 spawn 出来立刻收掉
3683
+ if (runState.stopping === true) {
3684
+ try {
3685
+ process.kill('SIGTERM');
3686
+ } catch (e) {
3687
+ // 进程可能已经退出,忽略
3688
+ }
3689
+ }
3742
3690
 
3743
3691
  return await new Promise((resolve, reject) => {
3744
3692
  const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
@@ -3798,7 +3746,13 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
3798
3746
  }
3799
3747
  onEvent({
3800
3748
  type: 'content_delta',
3801
- content: contentDeltaAccumulator
3749
+ content: contentDeltaAccumulator,
3750
+ // token 级增量标记为 partial:调用方据此跳过事件日志、
3751
+ // 并对历史落盘做节流(否则每个 token 都要重写一次历史 JSON);
3752
+ // chunk/chunkReset 只在 partial 时有意义,见 extractContentDeltaFromPayload
3753
+ partial: deltaContent.partial === true,
3754
+ chunk: typeof deltaContent.chunk === 'string' ? deltaContent.chunk : '',
3755
+ chunkReset: deltaContent.chunkReset === true
3802
3756
  });
3803
3757
  }
3804
3758
  return;
@@ -3850,11 +3804,11 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
3850
3804
  });
3851
3805
 
3852
3806
  process.on('error', error => {
3853
- state.agentRuns.delete(sessionRef.containerName);
3807
+ releaseWebAgentRun(state, sessionRef.containerName, runState);
3854
3808
  reject(error);
3855
3809
  });
3856
3810
  process.on('close', code => {
3857
- state.agentRuns.delete(sessionRef.containerName);
3811
+ releaseWebAgentRun(state, sessionRef.containerName, runState);
3858
3812
  if (stdoutPending) {
3859
3813
  emitStdoutTraceLine(stdoutPending);
3860
3814
  stdoutPending = '';
@@ -3920,9 +3874,9 @@ function sendNdjson(res, payload) {
3920
3874
  res.write(`${JSON.stringify(payload)}\n`);
3921
3875
  }
3922
3876
 
3923
- function createWebStreamEmitter(res, state, sessionRef) {
3877
+ function createWebStreamEmitter(res, webHistoryDir, sessionRef) {
3924
3878
  return (payload, type, data = {}) => {
3925
- const controlEvent = appendWebSessionControlEvent(state, sessionRef, type, data);
3879
+ const controlEvent = appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data);
3926
3880
  sendNdjson(res, { ...payload, controlEvent });
3927
3881
  };
3928
3882
  }
@@ -3937,9 +3891,43 @@ function resolveWebStreamControlEventType(event) {
3937
3891
  return 'process.stdout';
3938
3892
  }
3939
3893
 
3894
+ // 容器级运行锁的"占位"。必须在路由里同步调用:从 409 检查到真正 spawn 之间隔着
3895
+ // readJsonBody / prepareWebAgentExecution(含 ensureWebContainer 拉起容器)等多个
3896
+ // await,容器冷启动时这个窗口有好几秒。不先占住的话第二次发送会穿过 409 检查,
3897
+ // 同一容器里真的跑起两个 agent 进程,而且后者会覆盖前者的运行登记,
3898
+ // 让第一个进程再也停不掉
3899
+ function reserveWebAgentRun(state, sessionRef) {
3900
+ const runState = {
3901
+ containerName: sessionRef.containerName,
3902
+ sessionKey: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
3903
+ process: null,
3904
+ command: '',
3905
+ startedAt: new Date().toISOString(),
3906
+ stopping: false
3907
+ };
3908
+ state.agentRuns.set(sessionRef.containerName, runState);
3909
+ return runState;
3910
+ }
3911
+
3912
+ // 只有占位仍是自己那一份时才释放,避免误删别人的登记
3913
+ function releaseWebAgentRun(state, containerName, runState) {
3914
+ if (state.agentRuns.get(containerName) === runState) {
3915
+ state.agentRuns.delete(containerName);
3916
+ }
3917
+ }
3918
+
3940
3919
  function stopWebAgentRun(state, containerName) {
3941
3920
  const runState = state.agentRuns.get(containerName);
3942
- if (!runState || !runState.process || runState.process.killed) {
3921
+ if (!runState) {
3922
+ return false;
3923
+ }
3924
+ if (!runState.process) {
3925
+ // 还在准备阶段(拉起容器 / 探测 resume),进程尚未 spawn:先记下停止意图,
3926
+ // execAgentInWebContainerStream 拿到进程后会立刻收掉它
3927
+ runState.stopping = true;
3928
+ return true;
3929
+ }
3930
+ if (runState.process.killed) {
3943
3931
  return false;
3944
3932
  }
3945
3933
  runState.stopping = true;
@@ -4016,10 +4004,15 @@ function getValidSessionRef(ctx, res, encodedName) {
4016
4004
  return parsed;
4017
4005
  }
4018
4006
 
4019
- function buildSessionSummary(ctx, state, containerMap, sessionRef) {
4007
+ // preloadedHistory:调用方已经读过同一个容器的历史时直接复用。历史 JSON 是整份
4008
+ // 读文件 + JSON.parse,容器多、agent 多时按 agent 数重复读会把 GET /api/sessions
4009
+ // 拖到秒级,而它是同步 IO——事件循环被占住期间 agent/stream 的输出只能攒着分批推
4010
+ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHistory = null) {
4020
4011
  const containerName = sessionRef && sessionRef.containerName ? sessionRef.containerName : '';
4021
4012
  const agentId = sessionRef && sessionRef.agentId ? sessionRef.agentId : WEB_DEFAULT_AGENT_ID;
4022
- const history = loadWebSessionHistory(state.webHistoryDir, containerName);
4013
+ const history = preloadedHistory && typeof preloadedHistory === 'object'
4014
+ ? preloadedHistory
4015
+ : loadWebSessionHistory(state.webHistoryDir, containerName);
4023
4016
  const realAgentSession = getWebAgentSession(history, agentId, { includeArchived: true });
4024
4017
  const agentSession = realAgentSession
4025
4018
  || (agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
@@ -4039,6 +4032,7 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef) {
4039
4032
  status: containerInfo.status || 'history',
4040
4033
  defaultCommand: containerInfo.defaultCommand || ''
4041
4034
  });
4035
+ const agentRunState = state.agentRuns ? state.agentRuns.get(containerName) : undefined;
4042
4036
  const createdAt = agentSession.createdAt || containerInfo.createdAt || null;
4043
4037
  const updatedAt = agentSession.updatedAt
4044
4038
  || (latestMessage && latestMessage.timestamp)
@@ -4065,6 +4059,11 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef) {
4065
4059
  hostPath: applied.hostPath || '',
4066
4060
  containerPath: applied.containerPath || '',
4067
4061
  archived: agentSession.archived === true,
4062
+ // 运行锁是容器级的,界面却是 agent 级的:把"容器在忙"和"是不是自己在忙"
4063
+ // 都透出去,同容器的其他 AGENT 才能提前禁用输入,而不是点了发送才吃 409
4064
+ containerBusy: agentRunState !== undefined,
4065
+ agentRunning: agentRunState !== undefined
4066
+ && agentRunState.sessionKey === buildWebSessionKey(containerName, agentId),
4068
4067
  ...(synthetic ? { synthetic: true } : {})
4069
4068
  };
4070
4069
  }
@@ -4116,7 +4115,7 @@ function buildSessionDetail(ctx, state, containerMap, name) {
4116
4115
  sessionRef.agentId,
4117
4116
  containerInfo.defaultCommand
4118
4117
  );
4119
- const summary = buildSessionSummary(ctx, state, containerMap, sessionRef);
4118
+ const summary = buildSessionSummary(ctx, state, containerMap, sessionRef, history);
4120
4119
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
4121
4120
  || (sessionRef.agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
4122
4121
  const latestMessage = agentSession && agentSession.messages.length
@@ -4159,7 +4158,7 @@ function buildSessionAudit(ctx, state, sessionRef) {
4159
4158
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4160
4159
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
4161
4160
  || createEmptyWebAgentSession(sessionRef.agentId);
4162
- const events = loadWebSessionControlEvents(state, sessionRef, agentSession.events);
4161
+ const events = loadWebSessionControlEvents(state.webHistoryDir, sessionRef, agentSession.events);
4163
4162
  const applied = history.applied && typeof history.applied === 'object' && !Array.isArray(history.applied)
4164
4163
  ? history.applied
4165
4164
  : {};
@@ -4290,123 +4289,6 @@ function renderLoginHtml(ctx) {
4290
4289
  return applyServeTitle(loadTemplate('login.html'), ctx);
4291
4290
  }
4292
4291
 
4293
- // DIAG_LOG:临时诊断日志查看页,自成一个内联 HTML 字符串(不接入 frontend-shadcn
4294
- // 构建流程),配合 /api/diag/logs 接口使用。排查结束后可整体删除本函数及其
4295
- // 调用点(全局搜索 "DIAG_LOG")。
4296
- function renderDiagLogsHtml() {
4297
- return `<!DOCTYPE html>
4298
- <html lang="zh-CN">
4299
- <head>
4300
- <meta charset="UTF-8">
4301
- <title>MANYOYO 诊断日志</title>
4302
- <style>
4303
- body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0b0d12; color: #d7dae0; }
4304
- header { padding: 12px 16px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; border-bottom: 1px solid #262b36; position: sticky; top: 0; background: #0b0d12; }
4305
- header input, header select { background: #171a21; border: 1px solid #2c313d; color: #d7dae0; padding: 5px 8px; border-radius: 4px; font-size: 13px; }
4306
- header button { background: #2b6cb0; border: none; color: #fff; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; }
4307
- header button.secondary { background: #2c313d; }
4308
- header label { font-size: 12px; display: flex; align-items: center; gap: 4px; }
4309
- main { padding: 8px 16px 40px; }
4310
- table { width: 100%; border-collapse: collapse; font-size: 12px; }
4311
- th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #1c202a; vertical-align: top; white-space: pre-wrap; word-break: break-all; }
4312
- th { position: sticky; top: 53px; background: #0b0d12; color: #8b93a3; font-weight: 600; }
4313
- tr:hover { background: #12151c; }
4314
- .cat { display: inline-block; padding: 1px 6px; border-radius: 3px; background: #1e2530; color: #8fd3ff; }
4315
- .status-ok { color: #7ee787; }
4316
- .status-err { color: #ff7b72; }
4317
- .dur-slow { color: #ffa657; font-weight: 600; }
4318
- #count { color: #8b93a3; font-size: 12px; }
4319
- </style>
4320
- </head>
4321
- <body>
4322
- <header>
4323
- <strong>诊断日志</strong>
4324
- <label>日期 <input id="f-date" type="text" placeholder="YYYY-MM-DD"></label>
4325
- <label>分类 <input id="f-category" type="text" placeholder="http_request / docker_exec / stream_event / stream_exec_start / stream_exec_end"></label>
4326
- <label>会话 <input id="f-session" type="text" placeholder="containerName 或 containerName~agentId"></label>
4327
- <label>关键字 <input id="f-keyword" type="text" placeholder="任意文本"></label>
4328
- <label>条数 <input id="f-limit" type="text" value="500" style="width:60px"></label>
4329
- <button id="btn-refresh">刷新</button>
4330
- <label><input id="f-auto" type="checkbox"> 每 2 秒自动刷新</label>
4331
- <span id="count"></span>
4332
- </header>
4333
- <main>
4334
- <table>
4335
- <thead><tr><th>时间</th><th>分类</th><th>详情</th></tr></thead>
4336
- <tbody id="rows"></tbody>
4337
- </table>
4338
- </main>
4339
- <script>
4340
- (function () {
4341
- var timer = null;
4342
- function fmtExtra(entry) {
4343
- var e = entry.extra || {};
4344
- var parts = [];
4345
- if (e.method) parts.push(e.method + ' ' + (e.path || ''));
4346
- if (typeof e.status === 'number') {
4347
- parts.push('<span class="' + (e.status < 400 ? 'status-ok' : 'status-err') + '">status=' + e.status + '</span>');
4348
- }
4349
- if (typeof e.durationMs === 'number') {
4350
- parts.push('<span class="' + (e.durationMs > 1000 ? 'dur-slow' : '') + '">' + e.durationMs + 'ms</span>');
4351
- }
4352
- if (e.session) parts.push('session=' + e.session);
4353
- if (e.args) parts.push('args=' + JSON.stringify(e.args));
4354
- if (e.type) parts.push('type=' + e.type);
4355
- if (typeof e.seq === 'number') parts.push('seq=' + e.seq);
4356
- if (typeof e.sinceStartMs === 'number') parts.push('sinceStart=' + e.sinceStartMs + 'ms');
4357
- if (typeof e.totalDurationMs === 'number') parts.push('total=' + e.totalDurationMs + 'ms');
4358
- if (typeof e.eventCount === 'number') parts.push('events=' + e.eventCount);
4359
- if (e.error) parts.push('<span class="status-err">error=' + e.error + '</span>');
4360
- var rest = {};
4361
- Object.keys(e).forEach(function (k) {
4362
- if (['method','path','status','durationMs','session','args','type','seq','sinceStartMs','totalDurationMs','eventCount','error'].indexOf(k) === -1) {
4363
- rest[k] = e[k];
4364
- }
4365
- });
4366
- if (Object.keys(rest).length) parts.push(JSON.stringify(rest));
4367
- return parts.join(' ');
4368
- }
4369
- function escapeHtml(s) {
4370
- return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
4371
- return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
4372
- });
4373
- }
4374
- function load() {
4375
- var params = new URLSearchParams();
4376
- var date = document.getElementById('f-date').value.trim();
4377
- var category = document.getElementById('f-category').value.trim();
4378
- var session = document.getElementById('f-session').value.trim();
4379
- var keyword = document.getElementById('f-keyword').value.trim();
4380
- var limit = document.getElementById('f-limit').value.trim();
4381
- if (date) params.set('date', date);
4382
- if (category) params.set('category', category);
4383
- if (session) params.set('session', session);
4384
- if (keyword) params.set('keyword', keyword);
4385
- if (limit) params.set('limit', limit);
4386
- fetch('/api/diag/logs?' + params.toString(), { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
4387
- .then(function (r) { return r.json(); })
4388
- .then(function (data) {
4389
- var entries = data.entries || [];
4390
- document.getElementById('count').textContent = entries.length + ' 条(' + data.date + ')';
4391
- var rows = entries.slice().reverse().map(function (entry) {
4392
- return '<tr><td>' + escapeHtml(entry.ts) + '</td><td><span class="cat">' + escapeHtml(entry.category) + '</span></td><td>' + fmtExtra(entry) + '</td></tr>';
4393
- });
4394
- document.getElementById('rows').innerHTML = rows.join('');
4395
- });
4396
- }
4397
- document.getElementById('btn-refresh').addEventListener('click', load);
4398
- document.getElementById('f-auto').addEventListener('change', function (e) {
4399
- if (timer) { clearInterval(timer); timer = null; }
4400
- if (e.target.checked) { timer = setInterval(load, 2000); }
4401
- });
4402
- load();
4403
- })();
4404
- </script>
4405
- </body>
4406
- </html>
4407
- `;
4408
- }
4409
-
4410
4292
  function toPositiveInt(value, fallback) {
4411
4293
  const parsed = Number.parseInt(value, 10);
4412
4294
  if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -4700,23 +4582,6 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4700
4582
  }
4701
4583
  }
4702
4584
  const routes = [
4703
- // DIAG_LOG:临时诊断日志查询接口,配合 /logs 页面使用,排查完成后与
4704
- // diagLog/readDiagLogEntries 一起整体删除即可(全局搜索 "DIAG_LOG")。
4705
- {
4706
- method: 'GET',
4707
- match: currentPath => currentPath === '/api/diag/logs' ? [] : null,
4708
- handler: async () => {
4709
- const requestUrl = new URL(req.url || '/api/diag/logs', 'http://localhost');
4710
- const entries = readDiagLogEntries(ctx, {
4711
- date: requestUrl.searchParams.get('date') || undefined,
4712
- category: requestUrl.searchParams.get('category') || undefined,
4713
- session: requestUrl.searchParams.get('session') || undefined,
4714
- keyword: requestUrl.searchParams.get('keyword') || undefined,
4715
- limit: Number(requestUrl.searchParams.get('limit')) || undefined
4716
- });
4717
- sendJson(res, 200, { date: requestUrl.searchParams.get('date') || getLocalDateTag(), entries });
4718
- }
4719
- },
4720
4585
  {
4721
4586
  method: 'GET',
4722
4587
  match: currentPath => currentPath === '/api/fs/directories' ? [] : null,
@@ -4827,7 +4692,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4827
4692
  method: 'GET',
4828
4693
  match: currentPath => currentPath === '/api/system/capacity' ? [] : null,
4829
4694
  handler: async () => {
4830
- const containerMap = listWebManyoyoContainers(ctx, state);
4695
+ const containerMap = listWebManyoyoContainers(ctx);
4831
4696
  const diskPath = path.dirname(path.resolve(state.webConfigPath));
4832
4697
  const report = estimateContainerCapacity({
4833
4698
  runtimeCommand: ctx.dockerCmd || 'docker',
@@ -4904,7 +4769,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4904
4769
  method: 'GET',
4905
4770
  match: currentPath => currentPath === '/api/sessions' ? [] : null,
4906
4771
  handler: async () => {
4907
- const containerMap = listWebManyoyoContainers(ctx, state);
4772
+ const containerMap = listWebManyoyoContainers(ctx);
4908
4773
  const names = new Set([
4909
4774
  ...Object.keys(containerMap),
4910
4775
  ...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
@@ -4917,7 +4782,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4917
4782
  .map(agentSession => buildSessionSummary(ctx, state, containerMap, {
4918
4783
  containerName: name,
4919
4784
  agentId: agentSession.agentId
4920
- }))
4785
+ }, history))
4921
4786
  .filter(Boolean);
4922
4787
  })
4923
4788
  .sort(compareWebSessionCreatedDesc);
@@ -5302,7 +5167,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5302
5167
  return;
5303
5168
  }
5304
5169
 
5305
- const containerMap = listWebManyoyoContainers(ctx, state);
5170
+ const containerMap = listWebManyoyoContainers(ctx);
5306
5171
  const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
5307
5172
  sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
5308
5173
  }
@@ -5315,7 +5180,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5315
5180
  if (!sessionRef) {
5316
5181
  return;
5317
5182
  }
5318
- const containerMap = listWebManyoyoContainers(ctx, state);
5183
+ const containerMap = listWebManyoyoContainers(ctx);
5319
5184
  const containerInfo = containerMap[sessionRef.containerName] || {};
5320
5185
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
5321
5186
  const effectiveTemplate = resolveEffectiveAgentPromptCommandForSession(
@@ -5413,7 +5278,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5413
5278
  return;
5414
5279
  }
5415
5280
 
5416
- const containerMap = listWebManyoyoContainers(ctx, state);
5281
+ const containerMap = listWebManyoyoContainers(ctx);
5417
5282
  const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
5418
5283
  sendJson(res, 200, {
5419
5284
  saved: true,
@@ -5523,6 +5388,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5523
5388
  sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
5524
5389
  return;
5525
5390
  }
5391
+ // 紧接着检查同步占位,中间不能有 await,否则并发请求会同时穿过上面这道检查
5392
+ const runState = reserveWebAgentRun(state, sessionRef);
5526
5393
 
5527
5394
  const userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
5528
5395
  mode: 'agent',
@@ -5542,6 +5409,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5542
5409
  try {
5543
5410
  prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
5544
5411
  } catch (e) {
5412
+ releaseWebAgentRun(state, sessionRef.containerName, runState);
5545
5413
  removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
5546
5414
  removeWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id);
5547
5415
  sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
@@ -5562,7 +5430,30 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5562
5430
  'Cache-Control': 'no-store',
5563
5431
  'X-Accel-Buffering': 'no'
5564
5432
  });
5565
- const emitStreamEvent = createWebStreamEmitter(res, state, sessionRef);
5433
+ // agent 可能几十秒不产生任何输出(长 WebSearch、长 Bash、长思考),
5434
+ // 而反向代理按"上游多久没发字节"计空闲超时(nginx proxy_read_timeout
5435
+ // 默认 60s),超时会 RST 掉这条流,浏览器侧抛 network error。这里定期
5436
+ // 发一行 ping 保活;前端忽略该事件,也不写入会话事件日志
5437
+ const heartbeatMs = ctx.agentStreamHeartbeatMs || DEFAULT_AGENT_STREAM_HEARTBEAT_MS;
5438
+ let lastStreamWriteAt = Date.now();
5439
+ const heartbeatTimer = setInterval(() => {
5440
+ if (res.writableEnded || res.destroyed) {
5441
+ return;
5442
+ }
5443
+ if (Date.now() - lastStreamWriteAt < heartbeatMs) {
5444
+ return;
5445
+ }
5446
+ sendNdjson(res, { type: 'ping' });
5447
+ lastStreamWriteAt = Date.now();
5448
+ }, Math.max(50, Math.floor(heartbeatMs / 2)));
5449
+ if (typeof heartbeatTimer.unref === 'function') {
5450
+ heartbeatTimer.unref();
5451
+ }
5452
+ const rawEmitStreamEvent = createWebStreamEmitter(res, state.webHistoryDir, sessionRef);
5453
+ const emitStreamEvent = (...args) => {
5454
+ lastStreamWriteAt = Date.now();
5455
+ return rawEmitStreamEvent(...args);
5456
+ };
5566
5457
  emitStreamEvent({
5567
5458
  type: 'meta',
5568
5459
  containerName: sessionRef.containerName,
@@ -5591,40 +5482,31 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5591
5482
  pending: true
5592
5483
  });
5593
5484
 
5594
- const diagStreamStartedAt = Date.now(); // DIAG_LOG
5595
- let diagEventSeq = 0; // DIAG_LOG
5596
- diagLog(ctx, 'stream_exec_start', { // DIAG_LOG
5597
- session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5598
- agentProgram: agentMeta.agentProgram,
5599
- command
5600
- });
5485
+ let lastPartialPersistAt = 0;
5601
5486
  try {
5602
5487
  const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
5603
5488
  agentProgram: agentMeta.agentProgram,
5489
+ // 复用路由入口占好的那把锁,而不是另起一份运行登记
5490
+ runState,
5604
5491
  onEvent: event => {
5605
- diagEventSeq += 1; // DIAG_LOG
5606
- diagLog(ctx, 'stream_event', { // DIAG_LOG
5607
- session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5608
- seq: diagEventSeq,
5609
- type: event && event.type,
5610
- sinceStartMs: Date.now() - diagStreamStartedAt,
5611
- textLength: event && typeof event.text === 'string' ? event.text.length : undefined,
5612
- contentLength: event && typeof event.content === 'string' ? event.content.length : undefined
5613
- });
5492
+ // token 级增量:只走网线,不进事件日志,历史落盘按
5493
+ // AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
5494
+ const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
5614
5495
  if (event && event.type === 'trace' && event.text) {
5615
5496
  traceLines.push(String(event.text));
5616
5497
  if (event.traceEvent && typeof event.traceEvent === 'object') {
5617
5498
  traceEvents.push(event.traceEvent);
5618
5499
  }
5619
- if (shouldFlushLiveHistory(state, buildWebSessionKey(sessionRef.containerName, sessionRef.agentId))) {
5620
- patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5621
- content: traceLines.join('\n'),
5622
- traceEvents: traceEvents.slice(),
5623
- pending: true
5624
- });
5625
- }
5500
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5501
+ content: traceLines.join('\n'),
5502
+ traceEvents: traceEvents.slice(),
5503
+ pending: true
5504
+ });
5626
5505
  }
5627
5506
  if (event && event.type === 'content_delta' && typeof event.content === 'string') {
5507
+ const now = Date.now();
5508
+ const shouldPersist = !isPartialDelta
5509
+ || now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
5628
5510
  if (!streamingReplyMessageId) {
5629
5511
  const streamingReplyMessage = appendWebSessionMessage(
5630
5512
  state.webHistoryDir,
@@ -5640,26 +5522,30 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5640
5522
  streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
5641
5523
  ? streamingReplyMessage.id
5642
5524
  : '';
5643
- } else if (shouldFlushLiveHistory(state, buildWebSessionKey(sessionRef.containerName, sessionRef.agentId))) {
5525
+ lastPartialPersistAt = now;
5526
+ } else if (shouldPersist) {
5644
5527
  patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
5645
5528
  content: event.content,
5646
5529
  pending: true
5647
5530
  });
5531
+ lastPartialPersistAt = now;
5648
5532
  }
5649
5533
  }
5534
+ if (isPartialDelta) {
5535
+ lastStreamWriteAt = Date.now();
5536
+ // 只发增量片段:前端在 content_chunk 上做追加,
5537
+ // reset 表示换了一条 assistant 消息、从空白重新开始
5538
+ sendNdjson(res, event.chunkReset === true
5539
+ ? { type: 'content_chunk', text: '', reset: true }
5540
+ : { type: 'content_chunk', text: event.chunk });
5541
+ return;
5542
+ }
5650
5543
  emitStreamEvent(event, resolveWebStreamControlEventType(event), {
5651
5544
  transportType: event && event.type ? event.type : '',
5652
5545
  text: event && (event.text || event.content) ? String(event.text || event.content) : ''
5653
5546
  });
5654
5547
  }
5655
5548
  });
5656
- diagLog(ctx, 'stream_exec_end', { // DIAG_LOG
5657
- session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5658
- totalDurationMs: Date.now() - diagStreamStartedAt,
5659
- eventCount: diagEventSeq,
5660
- interrupted: result.interrupted === true,
5661
- exitCode: result.exitCode
5662
- });
5663
5549
  traceLines.push(result.interrupted === true ? '[任务] 已停止' : '[任务] 已完成');
5664
5550
  patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
5665
5551
  pending: false,
@@ -5723,6 +5609,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5723
5609
  error: e && e.message ? e.message : 'Agent 执行失败'
5724
5610
  }, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
5725
5611
  } finally {
5612
+ clearInterval(heartbeatTimer);
5726
5613
  res.end();
5727
5614
  }
5728
5615
  }
@@ -5743,7 +5630,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5743
5630
  sendJson(res, 404, { error: '当前会话没有运行中的 agent 任务' });
5744
5631
  return;
5745
5632
  }
5746
- appendWebSessionControlEvent(state, sessionRef, 'session.stopping');
5633
+ appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopping');
5747
5634
  sendJson(res, 200, { ok: true, stopping: true });
5748
5635
  }
5749
5636
  },
@@ -5762,7 +5649,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5762
5649
 
5763
5650
  if (removeHistory) {
5764
5651
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
5765
- removeAllAgentHistoryArtifacts(state, sessionRef.containerName, Object.keys(history.agents || {}));
5652
+ removeAllAgentHistoryArtifacts(state.webHistoryDir, sessionRef.containerName, Object.keys(history.agents || {}));
5766
5653
  removeWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
5767
5654
  } else if (removedContainer) {
5768
5655
  appendWebSessionMessage(state.webHistoryDir, sessionRef, 'system', `容器 ${sessionRef.containerName} 已删除。`);
@@ -5791,7 +5678,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5791
5678
  if (history.agents && typeof history.agents === 'object') {
5792
5679
  if (removeHistory) {
5793
5680
  delete history.agents[sessionRef.agentId];
5794
- removeAllAgentHistoryArtifacts(state, sessionRef.containerName, [sessionRef.agentId]);
5681
+ removeAllAgentHistoryArtifacts(state.webHistoryDir, sessionRef.containerName, [sessionRef.agentId]);
5795
5682
  } else if (history.agents[sessionRef.agentId]) {
5796
5683
  history.agents[sessionRef.agentId].archived = true;
5797
5684
  history.agents[sessionRef.agentId].updatedAt = new Date().toISOString();
@@ -5861,6 +5748,10 @@ async function startWebServer(options) {
5861
5748
  showImagePullHint: options.showImagePullHint,
5862
5749
  removeContainer: options.removeContainer,
5863
5750
  logger: options.logger && typeof options.logger.info === 'function' ? options.logger : fallbackLogger,
5751
+ // agent/stream 的保活心跳间隔,需小于反向代理的空闲读超时(nginx 默认 60s)
5752
+ agentStreamHeartbeatMs: Number.isFinite(options.agentStreamHeartbeatMs) && options.agentStreamHeartbeatMs > 0
5753
+ ? options.agentStreamHeartbeatMs
5754
+ : DEFAULT_AGENT_STREAM_HEARTBEAT_MS,
5864
5755
  colors: options.colors || {
5865
5756
  GREEN: '',
5866
5757
  CYAN: '',
@@ -5873,29 +5764,6 @@ async function startWebServer(options) {
5873
5764
  throw new Error('Web 认证配置缺失,请设置 serve -U / serve -P');
5874
5765
  }
5875
5766
 
5876
- // DIAG_LOG:包一层记录每次 docker/podman 子进程调用的参数与耗时,覆盖
5877
- // listWebManyoyoContainers、ensureWebContainer 等所有经由 ctx.dockerExecArgs
5878
- // 发起的调用,不需要逐个调用点单独打点。
5879
- if (typeof ctx.dockerExecArgs === 'function') {
5880
- const rawDockerExecArgs = ctx.dockerExecArgs;
5881
- ctx.dockerExecArgs = (args, execOptions) => {
5882
- const startedAt = Date.now();
5883
- let errorMessage = '';
5884
- try {
5885
- return rawDockerExecArgs(args, execOptions);
5886
- } catch (e) {
5887
- errorMessage = e && e.message ? e.message : String(e);
5888
- throw e;
5889
- } finally {
5890
- diagLog(ctx, 'docker_exec', {
5891
- args: Array.isArray(args) ? args : [],
5892
- durationMs: Date.now() - startedAt,
5893
- error: errorMessage || undefined
5894
- });
5895
- }
5896
- };
5897
- }
5898
-
5899
5767
  const state = {
5900
5768
  webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
5901
5769
  webConfigPath: options.webConfigPath || getDefaultWebConfigPath(),
@@ -5903,11 +5771,6 @@ async function startWebServer(options) {
5903
5771
  terminalSessions: new Map(),
5904
5772
  agentRuns: new Map()
5905
5773
  };
5906
- // 跨请求复用同一个 FileEventStore 实例,append() 的增量缓存才能生效
5907
- // (否则每次都 new 一个新实例,缓存永远命中不到)
5908
- state.eventStore = new FileEventStore(state.webHistoryDir);
5909
- // shouldFlushLiveHistory() 的节流状态:aggregateId -> 上次成功落盘的时间戳
5910
- state._liveHistoryFlushThrottle = new Map();
5911
5774
 
5912
5775
  ensureWebHistoryDir(state.webHistoryDir);
5913
5776
 
@@ -5930,20 +5793,6 @@ async function startWebServer(options) {
5930
5793
  });
5931
5794
 
5932
5795
  const server = http.createServer(async (req, res) => {
5933
- // DIAG_LOG:记录每个 HTTP 请求从进入到响应完成(含长连接的 agent/stream,
5934
- // 'finish' 在 chunked 响应整体结束时才触发)的方法/路径/状态码/耗时。
5935
- const diagRequestStartedAt = Date.now();
5936
- res.on('finish', () => {
5937
- const rawPath = String(req.url || '').split('?')[0];
5938
- const sessionMatch = rawPath.match(/^\/api\/sessions\/([^/]+)/);
5939
- diagLog(ctx, 'http_request', {
5940
- method: req.method,
5941
- path: rawPath,
5942
- status: res.statusCode,
5943
- durationMs: Date.now() - diagRequestStartedAt,
5944
- session: sessionMatch ? decodeURIComponent(sessionMatch[1]) : undefined
5945
- });
5946
- });
5947
5796
  try {
5948
5797
  const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
5949
5798
  const url = new URL(req.url, `http://${req.headers.host || fallbackHost}`);
@@ -5978,13 +5827,6 @@ async function startWebServer(options) {
5978
5827
  return;
5979
5828
  }
5980
5829
 
5981
- // DIAG_LOG:临时诊断日志查看页,排查完成后与 renderDiagLogsHtml、
5982
- // /api/diag/logs 一起整体删除即可(全局搜索 "DIAG_LOG")。
5983
- if (req.method === 'GET' && pathname === '/logs') {
5984
- sendHtml(res, 200, renderDiagLogsHtml());
5985
- return;
5986
- }
5987
-
5988
5830
  const appFrontendMatch = pathname.match(/^\/app\/frontend\/([A-Za-z0-9._-]+)$/);
5989
5831
  if (req.method === 'GET' && appFrontendMatch) {
5990
5832
  const assetName = appFrontendMatch[1];