@xcanwin/manyoyo 8.6.6 → 8.6.8

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
@@ -58,6 +58,9 @@ const {
58
58
  const { FileEventStore } = require('../core/event-store');
59
59
  const historyStore = require('./history-store');
60
60
  const messagesView = require('./messages-view');
61
+ const agentRunControl = require('./agent-run-control');
62
+ const agentQueue = require('./agent-queue');
63
+ const { EventEmitter } = require('events');
61
64
  const zlib = require('zlib');
62
65
  const { buildManyoyoLogPath, getLocalDateTag } = require('../log-path');
63
66
  const { isValidLogDateTag, readServeLogEntries } = require('../serve-log-reader');
@@ -216,7 +219,9 @@ function createEmptyWebAgentSession(agentId, agentName) {
216
219
  lastResumeError: '',
217
220
  engineSessionId: '',
218
221
  usageTotal: null,
219
- model: ''
222
+ model: '',
223
+ queue: [],
224
+ queuePaused: false
220
225
  };
221
226
  }
222
227
 
@@ -268,7 +273,9 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
268
273
  lastResumeError: typeof source.lastResumeError === 'string' ? source.lastResumeError : '',
269
274
  engineSessionId: typeof source.engineSessionId === 'string' ? source.engineSessionId : '',
270
275
  usageTotal: normalizeWebAgentUsageTotal(source.usageTotal),
271
- model: normalizeStoredAgentModelValue(source.model)
276
+ model: normalizeStoredAgentModelValue(source.model),
277
+ queue: agentQueue.normalizeStoredQueue(source.queue),
278
+ queuePaused: source.queuePaused === true
272
279
  };
273
280
  }
274
281
 
@@ -828,6 +835,44 @@ function stripAnsi(text) {
828
835
  return text.replace(/\x1b\[[0-9;]*m/g, '');
829
836
  }
830
837
 
838
+ // Agent 原始输出只保留开头 AGENT_RAW_OUTPUT_MAX_CHARS(会话 id 在开头),另外保留一份结尾:
839
+ // 最终回复和用量都在输出末尾,超过上限后只看开头会把截断点之前的某条中间说明当成最终回复
840
+ const AGENT_RAW_OUTPUT_MAX_CHARS = 32 * 1024 * 1024;
841
+ const AGENT_OUTPUT_TAIL_MAX_CHARS = 8 * 1024 * 1024;
842
+ // 最终回复的上限:远大于正常回复,只挡住把整个文件当回复打出来的极端情况
843
+ const AGENT_FINAL_REPLY_MAX_CHARS = 1000000;
844
+
845
+ function createTailBuffer(maxChars) {
846
+ let parts = [];
847
+ let size = 0;
848
+ return {
849
+ push(text) {
850
+ if (!text) return;
851
+ parts.push(text);
852
+ size += text.length;
853
+ if (size > maxChars * 2) {
854
+ const joined = parts.join('').slice(-maxChars);
855
+ parts = [joined];
856
+ size = joined.length;
857
+ }
858
+ },
859
+ text() {
860
+ return parts.join('').slice(-maxChars);
861
+ }
862
+ };
863
+ }
864
+
865
+ // 结束后的回复文本:结构化输出里提取到的最终回复按 AGENT_FINAL_REPLY_MAX_CHARS(远大于正常回复)保留;
866
+ // 提取不到才退回原始输出,原始输出按 WEB_OUTPUT_MAX_CHARS 截断
867
+ function resolveAgentFinalOutput(agentProgram, { stdout, stdoutTruncated, stdoutTail, raw }) {
868
+ const extracted = (stdoutTruncated ? extractAgentMessageFromStructuredOutput(agentProgram, stdoutTail) : '')
869
+ || extractAgentMessageFromStructuredOutput(agentProgram, stdout);
870
+ if (extracted) {
871
+ return clipText(stripAnsi(extracted).trim(), AGENT_FINAL_REPLY_MAX_CHARS);
872
+ }
873
+ return clipText(stripAnsi(raw).trim() || '(无输出)');
874
+ }
875
+
831
876
  function clipText(text, maxChars = WEB_OUTPUT_MAX_CHARS) {
832
877
  if (typeof text !== 'string') return '';
833
878
  if (text.length <= maxChars) return text;
@@ -933,14 +978,26 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
933
978
  if (model) {
934
979
  flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
935
980
  }
981
+ // 子 Agent 说的话和思考默认不转发(v2.1.211+ 才有 --forward-subagent-text,旧版本不认这个参数会直接报错)。
982
+ // 'auto':在容器里随命令探测(claude --help 约 50ms),支持就展开成这个参数、不支持展开成空;true:直接加。
983
+ // 'auto' 的参数放在整条命令末尾,不改变前面已有参数的相对顺序
984
+ const forwardSubagentText = options && options.forwardSubagentText;
985
+ const templateHasForward = /(?:^|\s)--forward-subagent-text(?:\s|$)/.test(templateText);
986
+ if (forwardSubagentText === true) {
987
+ flagSpecs.push({ flag: '--forward-subagent-text', pattern: /(?:^|\s)--forward-subagent-text(?:\s|$)/ });
988
+ }
936
989
  const claudeTemplate = prependAgentFlags(
937
990
  templateText,
938
991
  /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/,
939
992
  flagSpecs
940
993
  );
941
- return claudeTemplate === templateText
994
+ const rendered = claudeTemplate === templateText
942
995
  ? renderAgentPromptCommand(templateText, prompt)
943
996
  : renderAgentPromptCommand(claudeTemplate, prompt);
997
+ if (forwardSubagentText === 'auto' && !templateHasForward) {
998
+ return `MANYOYO_FWD_SUBAGENT="$(claude --help 2>&1 | grep -q -- '--forward-subagent-text' && echo --forward-subagent-text)"; ${rendered} $MANYOYO_FWD_SUBAGENT`;
999
+ }
1000
+ return rendered;
944
1001
  }
945
1002
 
946
1003
  function buildGeminiAgentExecCommand(template, prompt, options = {}) {
@@ -1043,7 +1100,12 @@ function extractClaudeAgentMessage(text) {
1043
1100
  let lastMessage = '';
1044
1101
  for (const rawLine of String(text || '').split('\n')) {
1045
1102
  const payload = parseJsonObjectLine(rawLine);
1046
- if (!payload || payload.type !== 'assistant') {
1103
+ // result 事件带权威的最终文本;后台任务场景结尾有两个 result(第一个是中间结论),取最后一个
1104
+ if (payload && payload.type === 'result' && typeof payload.result === 'string' && payload.result.trim()) {
1105
+ lastMessage = payload.result.trim();
1106
+ continue;
1107
+ }
1108
+ if (!payload || payload.type !== 'assistant' || pickFirstString(payload.parent_tool_use_id)) {
1047
1109
  continue;
1048
1110
  }
1049
1111
  const message = toPlainObject(payload.message);
@@ -1594,7 +1656,21 @@ function createStructuredTraceEvent(provider, kind, eventType, textValue, extra
1594
1656
  };
1595
1657
  }
1596
1658
 
1659
+ // 派生子 Agent 的工具:v2.1.63 起叫 Agent,旧名字 Task 仍是别名,两个都认
1660
+ const CLAUDE_SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
1661
+
1662
+ // 子 Agent 里的 assistant / user 消息带 parent_tool_use_id(发起它的那次 Agent 调用的 id),主对话里是 null。
1663
+ // 只给事件加 parentToolId 字段,不改已有字段的含义
1597
1664
  function prepareClaudeTraceEvents(payload, state) {
1665
+ const events = prepareClaudeTraceEventsBase(payload, state);
1666
+ const parentToolId = pickFirstString(payload && payload.parent_tool_use_id);
1667
+ if (parentToolId) {
1668
+ events.forEach(event => { event.parentToolId = parentToolId; });
1669
+ }
1670
+ return events;
1671
+ }
1672
+
1673
+ function prepareClaudeTraceEventsBase(payload, state) {
1598
1674
  const eventType = pickFirstString(payload.type);
1599
1675
  const subtype = pickFirstString(payload.subtype);
1600
1676
  const message = toPlainObject(payload.message);
@@ -1632,6 +1708,23 @@ function prepareClaudeTraceEvents(payload, state) {
1632
1708
  if (toolId) {
1633
1709
  toolNamesById.set(toolId, toolName);
1634
1710
  }
1711
+ if (CLAUDE_SUBAGENT_TOOL_NAMES.has(toolName)) {
1712
+ const input = toPlainObject(item.input);
1713
+ const subagentType = pickFirstString(input.subagent_type, input.name);
1714
+ const description = pickFirstString(input.description, input.name);
1715
+ events.push(createStructuredTraceEvent('claude', 'subagent', eventType, `[子 Agent] ${[subagentType, description].filter(Boolean).join(' ') || toolName}`, {
1716
+ phase: 'started',
1717
+ status: 'in_progress',
1718
+ toolName,
1719
+ toolId,
1720
+ subagentType,
1721
+ description,
1722
+ prompt: pickFirstString(input.prompt),
1723
+ arguments: input,
1724
+ argumentSummary: summarizeTraceArguments(input)
1725
+ }));
1726
+ return;
1727
+ }
1635
1728
  const summary = summarizeTraceArguments(toPlainObject(item.input));
1636
1729
  events.push(createStructuredTraceEvent(
1637
1730
  'claude',
@@ -1659,7 +1752,7 @@ function prepareClaudeTraceEvents(payload, state) {
1659
1752
  const toolId = pickFirstString(item.tool_use_id);
1660
1753
  const toolName = pickFirstString(toolNamesById.get(toolId), toolId, 'tool');
1661
1754
  const status = item.is_error === true ? 'error' : 'success';
1662
- events.push(createStructuredTraceEvent('claude', 'tool', eventType, `[工具完成] ${toolName} (${status})`, {
1755
+ events.push(createStructuredTraceEvent('claude', CLAUDE_SUBAGENT_TOOL_NAMES.has(toolName) ? 'subagent' : 'tool', eventType, `[${CLAUDE_SUBAGENT_TOOL_NAMES.has(toolName) ? '子 Agent 完成' : '工具完成'}] ${toolName} (${status})`, {
1663
1756
  phase: 'completed',
1664
1757
  status,
1665
1758
  toolName,
@@ -1867,6 +1960,9 @@ function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
1867
1960
  // 一轮里可能有多条 assistant 消息(正文→工具→正文),message_start 清空累计,
1868
1961
  // 语义与下面整条 assistant 消息的 reset 一致
1869
1962
  if (payloadType === 'stream_event') {
1963
+ if (pickFirstString(payload.parent_tool_use_id)) {
1964
+ return null;
1965
+ }
1870
1966
  const streamEvent = toPlainObject(payload.event);
1871
1967
  const streamEventType = pickFirstString(streamEvent.type);
1872
1968
  if (streamEventType === 'message_start') {
@@ -1890,6 +1986,10 @@ function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
1890
1986
  if (payloadType !== 'assistant') {
1891
1987
  return null;
1892
1988
  }
1989
+ // 子 Agent 说的话(--forward-subagent-text)只属于执行过程,不是主 Agent 的回复
1990
+ if (pickFirstString(payload.parent_tool_use_id)) {
1991
+ return null;
1992
+ }
1893
1993
  const message = toPlainObject(payload.message);
1894
1994
  const content = Array.isArray(message.content) ? message.content : [];
1895
1995
  const text = content
@@ -2215,7 +2315,8 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2215
2315
  : buildAgentPromptWithHistory(agentSession, prompt);
2216
2316
  const command = buildWebAgentExecCommand(effectiveTemplate, effectivePrompt, agentMeta.agentProgram, {
2217
2317
  sessionId: sessionIdForRun,
2218
- model: agentSession.model || ''
2318
+ model: agentSession.model || '',
2319
+ forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false
2219
2320
  });
2220
2321
  const contextMode = resumeSucceeded ? 'resume' : (hasPriorConversation ? 'history-injected' : 'first-turn');
2221
2322
 
@@ -2233,7 +2334,8 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2233
2334
  }
2234
2335
 
2235
2336
  function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, meta, result) {
2236
- const turnUsage = extractTurnUsage(agentMeta.agentProgram, result.stdout);
2337
+ // 用量在输出末尾:原始输出超过上限时开头里没有它
2338
+ const turnUsage = extractTurnUsage(agentMeta.agentProgram, result.stdoutTail || result.stdout);
2237
2339
  const replyMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'assistant', result.output, {
2238
2340
  exitCode: result.exitCode,
2239
2341
  mode: 'agent',
@@ -3498,6 +3600,7 @@ async function startContainerServices(ctx, state) {
3498
3600
  const reconcile = () => reconcileContainerServices(ctx, state, manager);
3499
3601
  state.networkWatch = manager.watchStarts({ onReconnect: reconcile });
3500
3602
  await reconcile();
3603
+ resumeQueuedAgentTurns(ctx, state).catch(() => {});
3501
3604
  }
3502
3605
 
3503
3606
  // 拉起 autostartOnServe 的容器并恢复端口暴露:serve 启动时做一次,运行时重启后(事件流重连)再做一次,幂等
@@ -3719,7 +3822,8 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
3719
3822
  { stdio: ['ignore', 'pipe', 'pipe'] }
3720
3823
  );
3721
3824
 
3722
- const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
3825
+ const MAX_RAW_OUTPUT_CHARS = ctx.agentRawOutputMaxChars || AGENT_RAW_OUTPUT_MAX_CHARS;
3826
+ const stdoutTail = createTailBuffer(Math.min(MAX_RAW_OUTPUT_CHARS, AGENT_OUTPUT_TAIL_MAX_CHARS));
3723
3827
  let stdoutOutput = '';
3724
3828
  let stderrOutput = '';
3725
3829
  let stdoutTruncated = false;
@@ -3729,6 +3833,7 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
3729
3833
  if (!chunk) return;
3730
3834
  const text = chunk.toString('utf-8');
3731
3835
  if (!text) return;
3836
+ if (target.tail) target.tail.push(text);
3732
3837
  if (target.value.length >= MAX_RAW_OUTPUT_CHARS) {
3733
3838
  target.truncated = true;
3734
3839
  return;
@@ -3742,7 +3847,11 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
3742
3847
  target.value += text;
3743
3848
  }
3744
3849
 
3850
+ // 按流解码:逐块 toString 会把跨块的多字节字符(中文)拆成乱码
3851
+ process.stdout.setEncoding('utf8');
3852
+ process.stderr.setEncoding('utf8');
3745
3853
  process.stdout.on('data', chunk => appendChunk(chunk, {
3854
+ tail: stdoutTail,
3746
3855
  get value() { return stdoutOutput; },
3747
3856
  set value(nextValue) { stdoutOutput = nextValue; },
3748
3857
  get truncated() { return stdoutTruncated; },
@@ -3761,13 +3870,14 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
3761
3870
  const clippedStdout = stdoutTruncated ? `${stdoutOutput}\n...[stdout-truncated]` : stdoutOutput;
3762
3871
  const clippedStderr = stderrTruncated ? `${stderrOutput}\n...[stderr-truncated]` : stderrOutput;
3763
3872
  const clippedRaw = `${clippedStdout}${clippedStdout && clippedStderr ? '\n' : ''}${clippedStderr}`;
3764
- const extractedAgentMessage = extractAgentMessageFromStructuredOutput(agentProgram, clippedStdout);
3765
- const cleanOutputSource = extractedAgentMessage || clippedRaw;
3766
- const output = clipText(stripAnsi(cleanOutputSource).trim() || '(无输出)');
3873
+ // 结尾只在开头被截断时才需要(拼接最多 16MB,只算一次)
3874
+ const tailText = stdoutTruncated ? stdoutTail.text() : '';
3875
+ const output = resolveAgentFinalOutput(agentProgram, { stdout: clippedStdout, stdoutTruncated, stdoutTail: tailText, raw: clippedRaw });
3767
3876
  resolve({
3768
3877
  exitCode,
3769
3878
  output,
3770
3879
  stdout: clippedStdout,
3880
+ ...(stdoutTruncated ? { stdoutTail: tailText } : {}),
3771
3881
  stderr: clippedStderr
3772
3882
  });
3773
3883
  });
@@ -4151,7 +4261,8 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4151
4261
  process = spawnContainerExec(
4152
4262
  ctx,
4153
4263
  sessionRef.containerName,
4154
- { command: ['/bin/bash', '-lc', command] },
4264
+ // 包一层:新会话 + MANYOYO_RUN_ID + PID 文件,停止 / 打断时才能进到容器里按进程组发信号
4265
+ { command: ['/bin/bash', '-lc', agentRunControl.wrapAgentCommand(command, runState.runId)] },
4155
4266
  { stdio: ['ignore', 'pipe', 'pipe'] }
4156
4267
  );
4157
4268
  } catch (e) {
@@ -4166,15 +4277,12 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4166
4277
  state.agentRuns.set(sessionRef.containerName, runState);
4167
4278
  // 准备阶段(拉起容器等)用户就点了停止:进程刚 spawn 出来立刻收掉
4168
4279
  if (runState.stopping === true) {
4169
- try {
4170
- process.kill('SIGTERM');
4171
- } catch (e) {
4172
- // 进程可能已经退出,忽略
4173
- }
4280
+ terminateWebAgentRun(ctx, runState);
4174
4281
  }
4175
4282
 
4176
4283
  return await new Promise((resolve, reject) => {
4177
- const MAX_RAW_OUTPUT_CHARS = 32 * 1024 * 1024;
4284
+ const MAX_RAW_OUTPUT_CHARS = ctx.agentRawOutputMaxChars || AGENT_RAW_OUTPUT_MAX_CHARS;
4285
+ const stdoutTail = createTailBuffer(Math.min(MAX_RAW_OUTPUT_CHARS, AGENT_OUTPUT_TAIL_MAX_CHARS));
4178
4286
  let stdoutOutput = '';
4179
4287
  let stderrOutput = '';
4180
4288
  let stdoutTruncated = false;
@@ -4189,6 +4297,7 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4189
4297
  if (!chunk) return;
4190
4298
  const text = chunk.toString('utf-8');
4191
4299
  if (!text) return;
4300
+ if (target.tail) target.tail.push(text);
4192
4301
  if (target.value.length >= MAX_RAW_OUTPUT_CHARS) {
4193
4302
  target.truncated = true;
4194
4303
  return;
@@ -4269,8 +4378,12 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4269
4378
  return pending;
4270
4379
  }
4271
4380
 
4381
+ // 按流解码:逐块 toString 会把跨块的多字节字符(中文)拆成乱码
4382
+ process.stdout.setEncoding('utf8');
4383
+ process.stderr.setEncoding('utf8');
4272
4384
  process.stdout.on('data', chunk => {
4273
4385
  appendChunk(chunk, {
4386
+ tail: stdoutTail,
4274
4387
  get value() { return stdoutOutput; },
4275
4388
  set value(nextValue) { stdoutOutput = nextValue; },
4276
4389
  get truncated() { return stdoutTruncated; },
@@ -4290,10 +4403,12 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4290
4403
 
4291
4404
  process.on('error', error => {
4292
4405
  releaseWebAgentRun(state, sessionRef.containerName, runState);
4406
+ runState.markClosed();
4293
4407
  reject(error);
4294
4408
  });
4295
4409
  process.on('close', code => {
4296
4410
  releaseWebAgentRun(state, sessionRef.containerName, runState);
4411
+ runState.markClosed();
4297
4412
  if (stdoutPending) {
4298
4413
  emitStdoutTraceLine(stdoutPending);
4299
4414
  stdoutPending = '';
@@ -4306,14 +4421,16 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4306
4421
  const clippedStdout = stdoutTruncated ? `${stdoutOutput}\n...[stdout-truncated]` : stdoutOutput;
4307
4422
  const clippedStderr = stderrTruncated ? `${stderrOutput}\n...[stderr-truncated]` : stderrOutput;
4308
4423
  const clippedRaw = `${clippedStdout}${clippedStdout && clippedStderr ? '\n' : ''}${clippedStderr}`;
4309
- const extractedAgentMessage = extractAgentMessageFromStructuredOutput(agentProgram, clippedStdout);
4310
- const cleanOutputSource = extractedAgentMessage || clippedRaw;
4311
- const output = clipText(stripAnsi(cleanOutputSource).trim() || '(无输出)');
4424
+ // 结尾只在开头被截断时才需要(拼接最多 16MB,只算一次)
4425
+ const tailText = stdoutTruncated ? stdoutTail.text() : '';
4426
+ const output = resolveAgentFinalOutput(agentProgram, { stdout: clippedStdout, stdoutTruncated, stdoutTail: tailText, raw: clippedRaw });
4312
4427
  resolve({
4313
4428
  exitCode,
4314
4429
  output,
4315
4430
  stdout: clippedStdout,
4316
- interrupted: exitCode !== 0 && runState.stopping === true
4431
+ ...(stdoutTruncated ? { stdoutTail: tailText } : {}),
4432
+ // 真实的 claude 收到 SIGINT 会正常收尾、退出码为 0(L3 实测):发过信号就算被打断,不能只看退出码
4433
+ interrupted: runState.stopping === true && (exitCode !== 0 || runState.signaled === true)
4317
4434
  });
4318
4435
  });
4319
4436
  });
@@ -4361,15 +4478,16 @@ function readClientMessageId(res, payload) {
4361
4478
 
4362
4479
  // 同一个 clientMessageId 已经出现在该 agent 的消息里:这条提示词服务端已经收到过,不能再执行一遍
4363
4480
  // (前端在网络中断后重发同一条时,靠它兜住“服务端其实已经收到”的情况)
4364
- function rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId) {
4481
+ function rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId, options = {}) {
4365
4482
  if (!clientMessageId) {
4366
4483
  return false;
4367
4484
  }
4368
4485
  const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
4369
4486
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
4370
- const duplicated = Boolean(agentSession) && agentSession.messages.some(
4487
+ // includeQueue:排队中的也算收到过(追加请求网络中断后,前端把原文放回输入框再发,不能执行两次)
4488
+ const duplicated = Boolean(agentSession) && (agentSession.messages.some(
4371
4489
  message => message && message.role === 'user' && message.clientMessageId === clientMessageId
4372
- );
4490
+ ) || (options.includeQueue === true && agentQueue.getQueue(agentSession).some(item => item.clientMessageId === clientMessageId)));
4373
4491
  if (!duplicated) {
4374
4492
  return false;
4375
4493
  }
@@ -4385,7 +4503,7 @@ function claimClientMessage(res, webHistoryDir, sessionRef, clientMessageId) {
4385
4503
  if (!clientMessageId) {
4386
4504
  return () => {};
4387
4505
  }
4388
- if (rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId)) {
4506
+ if (rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId, { includeQueue: true })) {
4389
4507
  return null;
4390
4508
  }
4391
4509
  const key = `${path.resolve(webHistoryDir)}|${buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)}|${clientMessageId}`;
@@ -4502,12 +4620,18 @@ function resolveWebStreamControlEventType(event) {
4502
4620
  function reserveWebAgentRun(state, sessionRef) {
4503
4621
  const runState = {
4504
4622
  containerName: sessionRef.containerName,
4623
+ sessionRef: { containerName: sessionRef.containerName, agentId: sessionRef.agentId },
4505
4624
  sessionKey: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
4506
4625
  process: null,
4507
4626
  command: '',
4508
4627
  startedAt: new Date().toISOString(),
4509
- stopping: false
4628
+ stopping: false,
4629
+ // 进程 close 时 resolve:停止 / 打断时据此判断容器里的进程有没有退出(见 agent-run-control.js)
4630
+ runId: agentRunControl.createRunId(),
4631
+ closed: null,
4632
+ markClosed: null
4510
4633
  };
4634
+ runState.closed = new Promise(resolve => { runState.markClosed = resolve; });
4511
4635
  state.agentRuns.set(sessionRef.containerName, runState);
4512
4636
  return runState;
4513
4637
  }
@@ -4519,11 +4643,172 @@ function releaseWebAgentRun(state, containerName, runState) {
4519
4643
  }
4520
4644
  }
4521
4645
 
4522
- function stopWebAgentRun(state, containerName) {
4646
+ // 追加提示词时打断当前这一轮(立即 / 当前步骤结束后 / 步骤超时):和用户停止一样走进程组信号,
4647
+ // 区别是不置 userStopped,这一轮结束后会接着发队首
4648
+ function steerWebAgentRun(ctx, runState, reason, state = null) {
4649
+ if (!runState || runState.steered || runState.stopping) {
4650
+ return false;
4651
+ }
4652
+ // 排队的 now / step 项已被取消:不要白白打断正在跑的任务
4653
+ if (state && !queueHasSteerItems(state, runState.sessionRef)) {
4654
+ clearTimeout(runState.stepTimer);
4655
+ runState.stepPending = false;
4656
+ return false;
4657
+ }
4658
+ clearTimeout(runState.stepTimer);
4659
+ runState.steered = reason;
4660
+ runState.stopping = true;
4661
+ if (runState.process) {
4662
+ terminateWebAgentRun(ctx, runState);
4663
+ }
4664
+ return true;
4665
+ }
4666
+
4667
+ function queueHasSteerItems(state, sessionRef) {
4668
+ return peekAgentQueue(state, sessionRef).some(item => item.when === 'now' || item.when === 'step');
4669
+ }
4670
+
4671
+ function armStepSteer(ctx, state, runState) {
4672
+ if (runState.steered || runState.stopping || runState.stepPending) {
4673
+ return;
4674
+ }
4675
+ runState.stepPending = true;
4676
+ runState.stepTimer = setTimeout(() => {
4677
+ if (steerWebAgentRun(ctx, runState, 'step-timeout', state)) {
4678
+ ctx.logger.warn('agent steer step fallback to now', { session: runState.sessionKey });
4679
+ }
4680
+ }, ctx.agentStepFallbackMs || agentQueue.STEP_FALLBACK_MS);
4681
+ if (typeof runState.stepTimer.unref === 'function') {
4682
+ runState.stepTimer.unref();
4683
+ }
4684
+ }
4685
+
4686
+ // 没有前端连着时顶替 res:事件照常落盘,只是没人收
4687
+ function createHeadlessResponse() {
4688
+ const sink = new EventEmitter();
4689
+ sink.writableEnded = false;
4690
+ sink.writableFinished = false;
4691
+ sink.destroyed = false;
4692
+ sink.writeHead = () => {};
4693
+ sink.write = () => true;
4694
+ sink.end = () => {
4695
+ sink.writableEnded = true;
4696
+ sink.writableFinished = true;
4697
+ sink.emit('close');
4698
+ };
4699
+ return sink;
4700
+ }
4701
+
4702
+ function peekAgentSession(state, sessionRef) {
4703
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4704
+ return getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
4705
+ }
4706
+
4707
+ function peekAgentQueue(state, sessionRef) {
4708
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4709
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
4710
+ return agentSession && Array.isArray(agentSession.queue) ? agentSession.queue : [];
4711
+ }
4712
+
4713
+ // 同步取出队首并开始新一轮(取出和占锁之间没有 await,提示词不会丢也不会重复发)。
4714
+ // 开不起来(容器忙、准备失败)就放回队首,由用户决定
4715
+ // force:用户点了“继续发送”,解除暂停后发;其余(自动调度)遇到暂停的队列不发
4716
+ function dispatchNextQueued(ctx, state, sessionRef, { force = false } = {}) {
4717
+ if (state.agentRuns.has(sessionRef.containerName)) {
4718
+ return false;
4719
+ }
4720
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4721
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
4722
+ if (!agentSession || !agentQueue.getQueue(agentSession).length) {
4723
+ return false;
4724
+ }
4725
+ if (force) {
4726
+ agentQueue.resumeQueue(agentSession);
4727
+ } else if (agentQueue.isPaused(agentSession)) {
4728
+ return false;
4729
+ }
4730
+ const item = agentQueue.takeNext(agentSession);
4731
+ saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
4732
+ const putBack = reason => {
4733
+ const latest = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4734
+ const latestSession = getWebAgentSession(latest, sessionRef.agentId, { includeArchived: true });
4735
+ if (latestSession) {
4736
+ agentQueue.getQueue(latestSession).unshift(item);
4737
+ saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, latest);
4738
+ }
4739
+ ctx.logger.warn('queued prompt put back', { session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), reason });
4740
+ };
4741
+ runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse())
4742
+ .then(turn => {
4743
+ if (turn && turn.busy) putBack('busy');
4744
+ else if (turn && turn.error) putBack(turn.error);
4745
+ })
4746
+ .catch(e => putBack(e && e.message ? e.message : 'failed'));
4747
+ return true;
4748
+ }
4749
+
4750
+ // 容器里还活着的运行 id(上一个 serve 留下的孤儿任务,或另一个 serve 实例正在跑的任务);读不到当作没有
4751
+ function listLiveRunsInContainer(ctx, containerName) {
4752
+ return new Promise(resolve => {
4753
+ let child;
4754
+ try {
4755
+ child = spawnContainerExec(ctx, containerName, { command: agentRunControl.buildLiveRunsCommand() }, { stdio: ['ignore', 'pipe', 'ignore'] });
4756
+ } catch (e) {
4757
+ resolve([]);
4758
+ return;
4759
+ }
4760
+ let out = '';
4761
+ child.stdout.on('data', chunk => { out += chunk; });
4762
+ child.once('close', () => resolve(out.split('\n').map(line => line.trim()).filter(agentRunControl.isValidRunId)));
4763
+ child.once('error', () => resolve([]));
4764
+ });
4765
+ }
4766
+
4767
+ // serve 重启后:队列里还有内容(用户停止过的除外)、又没有在跑的任务,就当作“这一轮结束后发送”处理。
4768
+ // 容器里还有活着的 Agent 进程时不发:那是上一个 serve 留下的孤儿任务或另一个 serve 实例的任务,
4769
+ // 再开一轮会和它并发;排队保留,由用户在界面上“继续发送”
4770
+ async function resumeQueuedAgentTurns(ctx, state) {
4771
+ let containers = {};
4772
+ try {
4773
+ containers = listWebManyoyoContainers(ctx);
4774
+ } catch (e) {
4775
+ return;
4776
+ }
4777
+ for (const containerName of Object.keys(containers)) {
4778
+ const history = loadWebSessionHistory(state.webHistoryDir, containerName);
4779
+ const agentIds = Object.keys(history.agents || {}).filter(agentId => agentQueue.shouldAutoDispatch(history.agents[agentId]));
4780
+ if (!agentIds.length) {
4781
+ continue;
4782
+ }
4783
+ const live = await listLiveRunsInContainer(ctx, containerName);
4784
+ if (live.length) {
4785
+ ctx.logger.warn('queued prompts kept: agent still running in container', { container: containerName, runs: live.length });
4786
+ continue;
4787
+ }
4788
+ for (const agentId of agentIds) {
4789
+ dispatchNextQueued(ctx, state, { containerName, agentId });
4790
+ }
4791
+ }
4792
+ }
4793
+
4794
+ // signals:默认 INT → TERM → KILL(用户点停止);ctx 缺省时退回只杀宿主机上的客户端
4795
+ function stopWebAgentRun(state, containerName, ctx = null) {
4523
4796
  const runState = state.agentRuns.get(containerName);
4524
4797
  if (!runState) {
4525
4798
  return false;
4526
4799
  }
4800
+ // 用户主动停止:排队的提示词不再自动发送(队列保留,由用户决定取消或继续)。
4801
+ // 暂停要落盘:只记在这一轮的内存里的话,serve 重启或下一轮结束后会被自动发出去
4802
+ runState.userStopped = true;
4803
+ if (runState.sessionRef) {
4804
+ const history = loadWebSessionHistory(state.webHistoryDir, runState.sessionRef.containerName);
4805
+ const agentSession = getWebAgentSession(history, runState.sessionRef.agentId, { includeArchived: true });
4806
+ if (agentSession && agentQueue.getQueue(agentSession).length && !agentQueue.isPaused(agentSession)) {
4807
+ agentQueue.pauseQueue(agentSession);
4808
+ saveWebSessionHistory(state.webHistoryDir, runState.sessionRef.containerName, history);
4809
+ }
4810
+ }
4811
+ clearTimeout(runState.stepTimer);
4527
4812
  if (!runState.process) {
4528
4813
  // 还在准备阶段(拉起容器 / 探测 resume),进程尚未 spawn:先记下停止意图,
4529
4814
  // execAgentInWebContainerStream 拿到进程后会立刻收掉它
@@ -4534,14 +4819,42 @@ function stopWebAgentRun(state, containerName) {
4534
4819
  return false;
4535
4820
  }
4536
4821
  runState.stopping = true;
4537
- try {
4538
- runState.process.kill('SIGTERM');
4539
- } catch (e) {
4540
- // 进程可能已自行退出,视为停止成功(close 事件会收尾)
4822
+ if (!ctx) {
4823
+ try {
4824
+ runState.process.kill('SIGTERM');
4825
+ } catch (e) {
4826
+ // 进程可能已自行退出,视为停止成功(close 事件会收尾)
4827
+ }
4828
+ return true;
4541
4829
  }
4830
+ terminateWebAgentRun(ctx, runState);
4542
4831
  return true;
4543
4832
  }
4544
4833
 
4834
+ // 进到容器里按进程组停:只杀宿主机上的 exec 客户端,容器里的进程会继续跑(实测)
4835
+ function terminateWebAgentRun(ctx, runState) {
4836
+ if (runState.terminating) {
4837
+ return runState.terminating;
4838
+ }
4839
+ runState.terminating = agentRunControl.terminateRun({
4840
+ runState,
4841
+ graceMs: ctx.agentStopGraceMs || agentRunControl.DEFAULT_GRACE_MS,
4842
+ killInContainer: (runId, signal) => new Promise(resolve => {
4843
+ runState.signaled = true;
4844
+ let child;
4845
+ try {
4846
+ child = spawnContainerExec(ctx, runState.containerName, { command: agentRunControl.buildRunKillCommand(runId, signal) }, { stdio: 'ignore' });
4847
+ } catch (e) {
4848
+ resolve();
4849
+ return;
4850
+ }
4851
+ child.once('close', resolve);
4852
+ child.once('error', resolve);
4853
+ })
4854
+ }).catch(() => {});
4855
+ return runState.terminating;
4856
+ }
4857
+
4545
4858
  // serve 重启等原因会丢失 agentRuns 运行登记,但消息里可能残留 pending 标记
4546
4859
  // (孤儿任务:容器里的进程或许还在跑,但再没有任何代码会去收尾这些消息)。
4547
4860
  // 前端把 pending 视为"任务进行中"会永久禁用输入,这里把它们标记为已中断
@@ -4568,6 +4881,310 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
4568
4881
  saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
4569
4882
  }
4570
4883
 
4884
+ // 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
4885
+ // 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
4886
+ // 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
4887
+ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res) {
4888
+ if (state.agentRuns.has(sessionRef.containerName)) {
4889
+ // 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
4890
+ return { busy: true };
4891
+ }
4892
+ // 紧接着检查同步占位,中间不能有 await,否则并发请求会同时穿过上面这道检查
4893
+ const runState = reserveWebAgentRun(state, sessionRef);
4894
+
4895
+ let userMessage;
4896
+ let traceMessage;
4897
+ try {
4898
+ userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
4899
+ mode: 'agent',
4900
+ pending: true,
4901
+ ...(clientMessageId ? { clientMessageId: clientMessageId } : {})
4902
+ });
4903
+ traceMessage = appendWebAgentTraceMessage(
4904
+ state.webHistoryDir,
4905
+ sessionRef,
4906
+ '[执行过程]\n等待 Agent 启动…',
4907
+ {
4908
+ traceEvents: [],
4909
+ pending: true
4910
+ }
4911
+ );
4912
+ } catch (e) {
4913
+ // 落盘失败(磁盘错误等):锁必须放掉,否则这个容器永远发不出消息
4914
+ releaseWebAgentRun(state, sessionRef.containerName, runState);
4915
+ throw e;
4916
+ }
4917
+
4918
+ let prepared = null;
4919
+ try {
4920
+ prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
4921
+ } catch (e) {
4922
+ releaseWebAgentRun(state, sessionRef.containerName, runState);
4923
+ removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
4924
+ removeWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id);
4925
+ return { error: e && e.message ? e.message : 'Agent 执行准备失败' };
4926
+ }
4927
+
4928
+ const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
4929
+ const traceLines = ['[执行过程]'];
4930
+ const traceEvents = [];
4931
+ let streamingReplyMessageId = '';
4932
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
4933
+ pending: true,
4934
+ contextMode
4935
+ });
4936
+
4937
+ res.writeHead(200, {
4938
+ 'Content-Type': 'application/x-ndjson; charset=utf-8',
4939
+ 'Cache-Control': 'no-store',
4940
+ 'X-Accel-Buffering': 'no'
4941
+ });
4942
+ // agent 流的生命周期日志:每轮最多三条(开始 / 客户端断开 / 结束),
4943
+ // 不是逐事件打点。没有这几条的话,用户报"跑到一半断流了"时服务端
4944
+ // 日志里查不到任何痕迹——断连本身只发生在客户端侧,服务端不主动感知
4945
+ const turnStartedAt = Date.now();
4946
+ const turnSessionKey = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
4947
+ ctx.logger.info('agent turn started', {
4948
+ session: turnSessionKey,
4949
+ agentProgram: agentMeta.agentProgram,
4950
+ contextMode,
4951
+ resumeAttempted,
4952
+ resumeSucceeded
4953
+ });
4954
+ // 'close' 先于 'finish' 触发且 writableFinished 仍为 false,说明是
4955
+ // 客户端/反代把连接掐了,而不是我们正常收尾。此时容器里的任务还在跑,
4956
+ // 前端会转入恢复轮询——日志要把这个区别写清楚,否则事后无法归因
4957
+ res.on('close', () => {
4958
+ if (res.writableFinished) {
4959
+ return;
4960
+ }
4961
+ ctx.logger.warn('agent stream client disconnected', {
4962
+ session: turnSessionKey,
4963
+ elapsedMs: Date.now() - turnStartedAt,
4964
+ runContinues: state.agentRuns.has(sessionRef.containerName)
4965
+ });
4966
+ });
4967
+ // agent 可能几十秒不产生任何输出(长 WebSearch、长 Bash、长思考),
4968
+ // 而反向代理按"上游多久没发字节"计空闲超时(nginx proxy_read_timeout
4969
+ // 默认 60s),超时会 RST 掉这条流,浏览器侧抛 network error。这里定期
4970
+ // 发一行 ping 保活;前端忽略该事件,也不写入会话事件日志
4971
+ const heartbeatMs = ctx.agentStreamHeartbeatMs || DEFAULT_AGENT_STREAM_HEARTBEAT_MS;
4972
+ let lastStreamWriteAt = Date.now();
4973
+ const heartbeatTimer = setInterval(() => {
4974
+ if (res.writableEnded || res.destroyed) {
4975
+ return;
4976
+ }
4977
+ if (Date.now() - lastStreamWriteAt < heartbeatMs) {
4978
+ return;
4979
+ }
4980
+ sendNdjson(res, { type: 'ping' });
4981
+ lastStreamWriteAt = Date.now();
4982
+ }, Math.max(50, Math.floor(heartbeatMs / 2)));
4983
+ if (typeof heartbeatTimer.unref === 'function') {
4984
+ heartbeatTimer.unref();
4985
+ }
4986
+ const rawEmitStreamEvent = createWebStreamEmitter(res, state.webHistoryDir, sessionRef);
4987
+ const emitStreamEvent = (...args) => {
4988
+ lastStreamWriteAt = Date.now();
4989
+ return rawEmitStreamEvent(...args);
4990
+ };
4991
+ emitStreamEvent({
4992
+ type: 'meta',
4993
+ containerName: sessionRef.containerName,
4994
+ sessionName: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
4995
+ // 前端用这两个 id 把本地乐观占位(local-* / __streaming_trace__)换成
4996
+ // 服务端持久化 id,轮询对账时 React key 才能对得上、DOM 不整体重建
4997
+ userMessageId: userMessage && userMessage.id,
4998
+ traceMessageId: traceMessage && traceMessage.id,
4999
+ contextMode,
5000
+ resumeAttempted,
5001
+ resumeSucceeded,
5002
+ agentProgram: agentMeta.agentProgram
5003
+ }, 'session.ready', { agentProgram: agentMeta.agentProgram });
5004
+ if (contextMode) {
5005
+ traceLines.push(`上下文模式: ${contextMode}`);
5006
+ }
5007
+ if (resumeAttempted) {
5008
+ traceLines.push(resumeSucceeded ? '会话恢复成功' : '会话恢复失败,已回退到历史注入');
5009
+ }
5010
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5011
+ content: traceLines.join('\n'),
5012
+ traceEvents: traceEvents.slice(),
5013
+ contextMode,
5014
+ resumeAttempted,
5015
+ resumeSucceeded,
5016
+ pending: true
5017
+ });
5018
+
5019
+ let lastPartialPersistAt = 0;
5020
+ const handleStreamEvent = event => {
5021
+ if (runState.stepPending && agentQueue.isStepBoundary(event)) {
5022
+ steerWebAgentRun(ctx, runState, 'step', state);
5023
+ }
5024
+ // token 级增量:只走网线,不进事件日志,历史落盘按
5025
+ // AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
5026
+ const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
5027
+ if (event && event.type === 'trace' && event.text) {
5028
+ traceLines.push(String(event.text));
5029
+ if (event.traceEvent && typeof event.traceEvent === 'object') {
5030
+ traceEvents.push(event.traceEvent);
5031
+ }
5032
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5033
+ content: traceLines.join('\n'),
5034
+ traceEvents: traceEvents.slice(),
5035
+ pending: true
5036
+ }, { defer: true });
5037
+ }
5038
+ if (event && event.type === 'content_delta' && typeof event.content === 'string') {
5039
+ const now = Date.now();
5040
+ const shouldPersist = !isPartialDelta
5041
+ || now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
5042
+ if (!streamingReplyMessageId) {
5043
+ const streamingReplyMessage = appendWebSessionMessage(
5044
+ state.webHistoryDir,
5045
+ sessionRef,
5046
+ 'assistant',
5047
+ event.content,
5048
+ {
5049
+ mode: 'agent',
5050
+ streamingReply: true,
5051
+ pending: true
5052
+ }
5053
+ );
5054
+ streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
5055
+ ? streamingReplyMessage.id
5056
+ : '';
5057
+ lastPartialPersistAt = now;
5058
+ } else if (shouldPersist) {
5059
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
5060
+ content: event.content,
5061
+ pending: true
5062
+ }, { defer: true });
5063
+ lastPartialPersistAt = now;
5064
+ }
5065
+ }
5066
+ if (isPartialDelta) {
5067
+ lastStreamWriteAt = Date.now();
5068
+ // 只发增量片段:前端在 content_chunk 上做追加,
5069
+ // reset 表示换了一条 assistant 消息、从空白重新开始
5070
+ sendNdjson(res, event.chunkReset === true
5071
+ ? { type: 'content_chunk', text: '', reset: true }
5072
+ : { type: 'content_chunk', text: event.chunk });
5073
+ return;
5074
+ }
5075
+ emitStreamEvent(event, resolveWebStreamControlEventType(event), {
5076
+ transportType: event && event.type ? event.type : '',
5077
+ text: event && (event.text || event.content) ? String(event.text || event.content) : ''
5078
+ });
5079
+
5080
+ };
5081
+ // 有域名规则时被过滤代理拦下的新域名:对话进行中立即提示一次
5082
+ const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }));
5083
+ try {
5084
+ if (deniedNotifier) deniedNotifier.start();
5085
+ let result;
5086
+ try {
5087
+ result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
5088
+ agentProgram: agentMeta.agentProgram,
5089
+ // 复用路由入口占好的那把锁,而不是另起一份运行登记
5090
+ runState,
5091
+ onEvent: handleStreamEvent
5092
+ });
5093
+ } finally {
5094
+ if (deniedNotifier) deniedNotifier.stop();
5095
+ }
5096
+ ctx.logger.info('agent turn finished', {
5097
+ session: turnSessionKey,
5098
+ durationMs: Date.now() - turnStartedAt,
5099
+ exitCode: result.exitCode,
5100
+ interrupted: result.interrupted === true
5101
+ });
5102
+ traceLines.push(result.interrupted === true
5103
+ ? (runState.steered && runState.userStopped !== true ? '[任务] 已打断,继续执行新的提示词' : '[任务] 已停止')
5104
+ : '[任务] 已完成');
5105
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
5106
+ pending: false,
5107
+ contextMode,
5108
+ resumeAttempted,
5109
+ resumeSucceeded
5110
+ });
5111
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5112
+ content: traceLines.join('\n'),
5113
+ traceEvents,
5114
+ contextMode,
5115
+ resumeAttempted,
5116
+ resumeSucceeded,
5117
+ interrupted: result.interrupted === true,
5118
+ pending: false
5119
+ });
5120
+ if (streamingReplyMessageId) {
5121
+ removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
5122
+ }
5123
+ const replyMessage = finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
5124
+ contextMode,
5125
+ resumeAttempted,
5126
+ resumeSucceeded,
5127
+ resumeError,
5128
+ engineSessionId
5129
+ }, result);
5130
+ emitStreamEvent({
5131
+ type: 'result',
5132
+ exitCode: result.exitCode,
5133
+ output: result.output,
5134
+ // 最终 assistant 消息的持久化 id:前端把 __streaming__ 占位换成它,
5135
+ // 发送后的首次对账才不会重建整个消息列表 DOM(滚动位置/选区保持)
5136
+ replyMessageId: replyMessage && replyMessage.id,
5137
+ contextMode,
5138
+ resumeAttempted,
5139
+ resumeSucceeded,
5140
+ interrupted: result.interrupted === true
5141
+ }, result.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
5142
+ } catch (e) {
5143
+ ctx.logger.error('agent turn failed', {
5144
+ session: turnSessionKey,
5145
+ durationMs: Date.now() - turnStartedAt,
5146
+ message: e && e.message ? e.message : 'Agent 执行失败'
5147
+ });
5148
+ traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
5149
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
5150
+ pending: false,
5151
+ contextMode,
5152
+ resumeAttempted,
5153
+ resumeSucceeded
5154
+ });
5155
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5156
+ content: traceLines.join('\n'),
5157
+ traceEvents,
5158
+ contextMode,
5159
+ resumeAttempted,
5160
+ resumeSucceeded,
5161
+ interrupted: true,
5162
+ pending: false
5163
+ });
5164
+ if (streamingReplyMessageId) {
5165
+ removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
5166
+ }
5167
+ emitStreamEvent({
5168
+ type: 'error',
5169
+ error: e && e.message ? e.message : 'Agent 执行失败'
5170
+ }, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
5171
+ } finally {
5172
+ clearInterval(heartbeatTimer);
5173
+ clearTimeout(runState.stepTimer);
5174
+ // 用户停止的不自动发;其余(完成 / 失败 / 被追加打断)有排队就接着发队首
5175
+ const hasNext = runState.userStopped !== true && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
5176
+ if (hasNext && !res.writableEnded && !res.destroyed) {
5177
+ // 前端收到后改为跟随下一轮(走掉线恢复的那套轮询)
5178
+ sendNdjson(res, { type: 'queue_next' });
5179
+ }
5180
+ res.end();
5181
+ if (hasNext) {
5182
+ setImmediate(() => dispatchNextQueued(ctx, state, sessionRef));
5183
+ }
5184
+ }
5185
+ return { started: true };
5186
+ }
5187
+
4571
5188
  function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
4572
5189
  sendBody(res, statusCode, 'text/html; charset=utf-8', html, extraHeaders, options);
4573
5190
  }
@@ -6143,6 +6760,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6143
6760
  name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
6144
6761
  containerName: sessionRef.containerName,
6145
6762
  agentId: sessionRef.agentId,
6763
+ queue: agentQueue.publicQueue(agentSession),
6764
+ queuePaused: agentQueue.isPaused(agentSession),
6146
6765
  ...view
6147
6766
  }, {}, { etag });
6148
6767
  }
@@ -6689,285 +7308,109 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6689
7308
  if (!streamClientId) {
6690
7309
  return;
6691
7310
  }
6692
- if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value)) {
7311
+ if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
6693
7312
  return;
6694
7313
  }
6695
- if (state.agentRuns.has(sessionRef.containerName)) {
6696
- // 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
7314
+ const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res);
7315
+ if (turn.busy) {
6697
7316
  sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
7317
+ } else if (turn.error) {
7318
+ sendJson(res, 400, { error: turn.error });
7319
+ }
7320
+ }
7321
+ },
7322
+ {
7323
+ method: 'POST',
7324
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/queue$/),
7325
+ handler: async match => {
7326
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7327
+ if (!sessionRef) {
6698
7328
  return;
6699
7329
  }
6700
- // 紧接着检查同步占位,中间不能有 await,否则并发请求会同时穿过上面这道检查
6701
- const runState = reserveWebAgentRun(state, sessionRef);
6702
-
6703
- const userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
6704
- mode: 'agent',
6705
- pending: true,
6706
- ...(streamClientId.value ? { clientMessageId: streamClientId.value } : {})
6707
- });
6708
- const traceMessage = appendWebAgentTraceMessage(
6709
- state.webHistoryDir,
6710
- sessionRef,
6711
- '[执行过程]\n等待 Agent 启动…',
6712
- {
6713
- traceEvents: [],
6714
- pending: true
6715
- }
6716
- );
6717
-
6718
- let prepared = null;
6719
- try {
6720
- prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
6721
- } catch (e) {
6722
- releaseWebAgentRun(state, sessionRef.containerName, runState);
6723
- removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
6724
- removeWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id);
6725
- sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
7330
+ const payload = await readJsonBody(req);
7331
+ const text = (payload.prompt || '').trim();
7332
+ if (!text) {
7333
+ sendJson(res, 400, { error: 'prompt 不能为空' });
6726
7334
  return;
6727
7335
  }
6728
-
6729
- const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
6730
- const traceLines = ['[执行过程]'];
6731
- const traceEvents = [];
6732
- let streamingReplyMessageId = '';
6733
- patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
6734
- pending: true,
6735
- contextMode
6736
- });
6737
-
6738
- res.writeHead(200, {
6739
- 'Content-Type': 'application/x-ndjson; charset=utf-8',
6740
- 'Cache-Control': 'no-store',
6741
- 'X-Accel-Buffering': 'no'
6742
- });
6743
- // agent 流的生命周期日志:每轮最多三条(开始 / 客户端断开 / 结束),
6744
- // 不是逐事件打点。没有这几条的话,用户报"跑到一半断流了"时服务端
6745
- // 日志里查不到任何痕迹——断连本身只发生在客户端侧,服务端不主动感知
6746
- const turnStartedAt = Date.now();
6747
- const turnSessionKey = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
6748
- ctx.logger.info('agent turn started', {
6749
- session: turnSessionKey,
6750
- agentProgram: agentMeta.agentProgram,
6751
- contextMode,
6752
- resumeAttempted,
6753
- resumeSucceeded
6754
- });
6755
- // 'close' 先于 'finish' 触发且 writableFinished 仍为 false,说明是
6756
- // 客户端/反代把连接掐了,而不是我们正常收尾。此时容器里的任务还在跑,
6757
- // 前端会转入恢复轮询——日志要把这个区别写清楚,否则事后无法归因
6758
- res.on('close', () => {
6759
- if (res.writableFinished) {
6760
- return;
6761
- }
6762
- ctx.logger.warn('agent stream client disconnected', {
6763
- session: turnSessionKey,
6764
- elapsedMs: Date.now() - turnStartedAt,
6765
- runContinues: state.agentRuns.has(sessionRef.containerName)
6766
- });
6767
- });
6768
- // agent 可能几十秒不产生任何输出(长 WebSearch、长 Bash、长思考),
6769
- // 而反向代理按"上游多久没发字节"计空闲超时(nginx proxy_read_timeout
6770
- // 默认 60s),超时会 RST 掉这条流,浏览器侧抛 network error。这里定期
6771
- // 发一行 ping 保活;前端忽略该事件,也不写入会话事件日志
6772
- const heartbeatMs = ctx.agentStreamHeartbeatMs || DEFAULT_AGENT_STREAM_HEARTBEAT_MS;
6773
- let lastStreamWriteAt = Date.now();
6774
- const heartbeatTimer = setInterval(() => {
6775
- if (res.writableEnded || res.destroyed) {
6776
- return;
6777
- }
6778
- if (Date.now() - lastStreamWriteAt < heartbeatMs) {
6779
- return;
6780
- }
6781
- sendNdjson(res, { type: 'ping' });
6782
- lastStreamWriteAt = Date.now();
6783
- }, Math.max(50, Math.floor(heartbeatMs / 2)));
6784
- if (typeof heartbeatTimer.unref === 'function') {
6785
- heartbeatTimer.unref();
6786
- }
6787
- const rawEmitStreamEvent = createWebStreamEmitter(res, state.webHistoryDir, sessionRef);
6788
- const emitStreamEvent = (...args) => {
6789
- lastStreamWriteAt = Date.now();
6790
- return rawEmitStreamEvent(...args);
6791
- };
6792
- emitStreamEvent({
6793
- type: 'meta',
6794
- containerName: sessionRef.containerName,
6795
- sessionName: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
6796
- // 前端用这两个 id 把本地乐观占位(local-* / __streaming_trace__)换成
6797
- // 服务端持久化 id,轮询对账时 React key 才能对得上、DOM 不整体重建
6798
- userMessageId: userMessage && userMessage.id,
6799
- traceMessageId: traceMessage && traceMessage.id,
6800
- contextMode,
6801
- resumeAttempted,
6802
- resumeSucceeded,
6803
- agentProgram: agentMeta.agentProgram
6804
- }, 'session.ready', { agentProgram: agentMeta.agentProgram });
6805
- if (contextMode) {
6806
- traceLines.push(`上下文模式: ${contextMode}`);
7336
+ const when = agentQueue.normalizeWhen(payload.when === undefined ? 'turn' : payload.when);
7337
+ if (!when) {
7338
+ sendJson(res, 400, { error: 'when 只能是 now、step 或 turn' });
7339
+ return;
6807
7340
  }
6808
- if (resumeAttempted) {
6809
- traceLines.push(resumeSucceeded ? '会话恢复成功' : '会话恢复失败,已回退到历史注入');
7341
+ const queueClientId = readClientMessageId(res, payload);
7342
+ if (!queueClientId) {
7343
+ return;
6810
7344
  }
6811
- patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
6812
- content: traceLines.join('\n'),
6813
- traceEvents: traceEvents.slice(),
6814
- contextMode,
6815
- resumeAttempted,
6816
- resumeSucceeded,
6817
- pending: true
6818
- });
6819
-
6820
- let lastPartialPersistAt = 0;
6821
- const handleStreamEvent = event => {
6822
- // token 级增量:只走网线,不进事件日志,历史落盘按
6823
- // AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
6824
- const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
6825
- if (event && event.type === 'trace' && event.text) {
6826
- traceLines.push(String(event.text));
6827
- if (event.traceEvent && typeof event.traceEvent === 'object') {
6828
- traceEvents.push(event.traceEvent);
6829
- }
6830
- patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
6831
- content: traceLines.join('\n'),
6832
- traceEvents: traceEvents.slice(),
6833
- pending: true
6834
- }, { defer: true });
6835
- }
6836
- if (event && event.type === 'content_delta' && typeof event.content === 'string') {
6837
- const now = Date.now();
6838
- const shouldPersist = !isPartialDelta
6839
- || now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
6840
- if (!streamingReplyMessageId) {
6841
- const streamingReplyMessage = appendWebSessionMessage(
6842
- state.webHistoryDir,
6843
- sessionRef,
6844
- 'assistant',
6845
- event.content,
6846
- {
6847
- mode: 'agent',
6848
- streamingReply: true,
6849
- pending: true
6850
- }
6851
- );
6852
- streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
6853
- ? streamingReplyMessage.id
6854
- : '';
6855
- lastPartialPersistAt = now;
6856
- } else if (shouldPersist) {
6857
- patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
6858
- content: event.content,
6859
- pending: true
6860
- }, { defer: true });
6861
- lastPartialPersistAt = now;
6862
- }
6863
- }
6864
- if (isPartialDelta) {
6865
- lastStreamWriteAt = Date.now();
6866
- // 只发增量片段:前端在 content_chunk 上做追加,
6867
- // reset 表示换了一条 assistant 消息、从空白重新开始
6868
- sendNdjson(res, event.chunkReset === true
6869
- ? { type: 'content_chunk', text: '', reset: true }
6870
- : { type: 'content_chunk', text: event.chunk });
6871
- return;
6872
- }
6873
- emitStreamEvent(event, resolveWebStreamControlEventType(event), {
6874
- transportType: event && event.type ? event.type : '',
6875
- text: event && (event.text || event.content) ? String(event.text || event.content) : ''
6876
- });
6877
-
6878
- };
6879
- // 有域名规则时被过滤代理拦下的新域名:对话进行中立即提示一次
6880
- const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }));
6881
- try {
6882
- if (deniedNotifier) deniedNotifier.start();
6883
- let result;
6884
- try {
6885
- result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
6886
- agentProgram: agentMeta.agentProgram,
6887
- // 复用路由入口占好的那把锁,而不是另起一份运行登记
6888
- runState,
6889
- onEvent: handleStreamEvent
6890
- });
6891
- } finally {
6892
- if (deniedNotifier) deniedNotifier.stop();
6893
- }
6894
- ctx.logger.info('agent turn finished', {
6895
- session: turnSessionKey,
6896
- durationMs: Date.now() - turnStartedAt,
6897
- exitCode: result.exitCode,
6898
- interrupted: result.interrupted === true
6899
- });
6900
- traceLines.push(result.interrupted === true ? '[任务] 已停止' : '[任务] 已完成');
6901
- patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
6902
- pending: false,
6903
- contextMode,
6904
- resumeAttempted,
6905
- resumeSucceeded
6906
- });
6907
- patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
6908
- content: traceLines.join('\n'),
6909
- traceEvents,
6910
- contextMode,
6911
- resumeAttempted,
6912
- resumeSucceeded,
6913
- interrupted: result.interrupted === true,
6914
- pending: false
6915
- });
6916
- if (streamingReplyMessageId) {
6917
- removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
6918
- }
6919
- const replyMessage = finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
6920
- contextMode,
6921
- resumeAttempted,
6922
- resumeSucceeded,
6923
- resumeError,
6924
- engineSessionId
6925
- }, result);
6926
- emitStreamEvent({
6927
- type: 'result',
6928
- exitCode: result.exitCode,
6929
- output: result.output,
6930
- // 最终 assistant 消息的持久化 id:前端把 __streaming__ 占位换成它,
6931
- // 发送后的首次对账才不会重建整个消息列表 DOM(滚动位置/选区保持)
6932
- replyMessageId: replyMessage && replyMessage.id,
6933
- contextMode,
6934
- resumeAttempted,
6935
- resumeSucceeded,
6936
- interrupted: result.interrupted === true
6937
- }, result.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
6938
- } catch (e) {
6939
- ctx.logger.error('agent turn failed', {
6940
- session: turnSessionKey,
6941
- durationMs: Date.now() - turnStartedAt,
6942
- message: e && e.message ? e.message : 'Agent 执行失败'
6943
- });
6944
- traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
6945
- patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
6946
- pending: false,
6947
- contextMode,
6948
- resumeAttempted,
6949
- resumeSucceeded
6950
- });
6951
- patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
6952
- content: traceLines.join('\n'),
6953
- traceEvents,
6954
- contextMode,
6955
- resumeAttempted,
6956
- resumeSucceeded,
6957
- interrupted: true,
6958
- pending: false
6959
- });
6960
- if (streamingReplyMessageId) {
6961
- removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
7345
+ if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, queueClientId.value)) {
7346
+ return;
7347
+ }
7348
+ const runState = state.agentRuns.get(sessionRef.containerName);
7349
+ if (runState && runState.sessionKey !== buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)) {
7350
+ sendJson(res, 409, { error: '当前容器已有其他会话的 agent 任务在运行,请等它结束或先停止' });
7351
+ return;
7352
+ }
7353
+ // 入队与下面的调度之间没有 await:队列落盘和打断判断对同一会话是原子的
7354
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
7355
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
7356
+ const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when });
7357
+ if (!added.ok) {
7358
+ sendJson(res, 409, { error: '排队已满(最多 10 条),请先取消几条或等它们发出', full: true });
7359
+ return;
7360
+ }
7361
+ if (!added.duplicate) {
7362
+ saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
7363
+ if (!runState) {
7364
+ dispatchNextQueued(ctx, state, sessionRef);
7365
+ } else if (when === 'now') {
7366
+ steerWebAgentRun(ctx, runState, 'now', state);
7367
+ } else if (when === 'step') {
7368
+ armStepSteer(ctx, state, runState);
6962
7369
  }
6963
- emitStreamEvent({
6964
- type: 'error',
6965
- error: e && e.message ? e.message : 'Agent 执行失败'
6966
- }, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
6967
- } finally {
6968
- clearInterval(heartbeatTimer);
6969
- res.end();
6970
7370
  }
7371
+ sendJson(res, 200, { item: added.item, duplicate: added.duplicate, queue: agentQueue.publicQueue(agentSession) });
7372
+ }
7373
+ },
7374
+ {
7375
+ method: 'DELETE',
7376
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/queue\/([A-Za-z0-9_-]+)$/),
7377
+ handler: async match => {
7378
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7379
+ if (!sessionRef) {
7380
+ return;
7381
+ }
7382
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
7383
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
7384
+ const removed = agentSession ? agentQueue.removeFromQueue(agentSession, match[2]) : null;
7385
+ if (!removed) {
7386
+ // 已经发出去了或早被取消:前端据此刷新,不当错误
7387
+ sendJson(res, 404, { error: '这条排队的提示词已经发出或已取消' });
7388
+ return;
7389
+ }
7390
+ saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
7391
+ // 取消的是最后一个 now / step:撤掉还在等的“步骤结束后打断”
7392
+ const activeRun = state.agentRuns.get(sessionRef.containerName);
7393
+ if (activeRun && activeRun.stepPending && !queueHasSteerItems(state, sessionRef)) {
7394
+ clearTimeout(activeRun.stepTimer);
7395
+ activeRun.stepPending = false;
7396
+ }
7397
+ sendJson(res, 200, { removed: removed.id, queue: agentQueue.publicQueue(agentSession) });
7398
+ }
7399
+ },
7400
+ {
7401
+ method: 'POST',
7402
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/queue\/continue$/),
7403
+ handler: async match => {
7404
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7405
+ if (!sessionRef) {
7406
+ return;
7407
+ }
7408
+ if (state.agentRuns.has(sessionRef.containerName)) {
7409
+ sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务' });
7410
+ return;
7411
+ }
7412
+ const started = dispatchNextQueued(ctx, state, sessionRef, { force: true });
7413
+ sendJson(res, 200, { started });
6971
7414
  }
6972
7415
  },
6973
7416
  {
@@ -6978,7 +7421,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6978
7421
  if (!sessionRef) {
6979
7422
  return;
6980
7423
  }
6981
- const stopped = stopWebAgentRun(state, sessionRef.containerName);
7424
+ const stopped = stopWebAgentRun(state, sessionRef.containerName, ctx);
6982
7425
  if (!stopped) {
6983
7426
  // 运行登记不存在(serve 重启后常见的孤儿任务):把残留的
6984
7427
  // pending 消息标记为已中断,否则前端会因 pending 永久禁用输入
@@ -7143,6 +7586,18 @@ async function startWebServer(options) {
7143
7586
  agentStreamHeartbeatMs: Number.isFinite(options.agentStreamHeartbeatMs) && options.agentStreamHeartbeatMs > 0
7144
7587
  ? options.agentStreamHeartbeatMs
7145
7588
  : DEFAULT_AGENT_STREAM_HEARTBEAT_MS,
7589
+ // 停止 / 打断时每一级信号(INT → TERM → KILL)等进程退出的时间
7590
+ agentStopGraceMs: Number.isFinite(options.agentStopGraceMs) && options.agentStopGraceMs > 0
7591
+ ? options.agentStopGraceMs
7592
+ : agentRunControl.DEFAULT_GRACE_MS,
7593
+ // Agent 原始输出保留开头的上限(测试可调小);结尾另留一份,见 createTailBuffer
7594
+ agentRawOutputMaxChars: Number.isFinite(options.agentRawOutputMaxChars) && options.agentRawOutputMaxChars > 0
7595
+ ? options.agentRawOutputMaxChars
7596
+ : AGENT_RAW_OUTPUT_MAX_CHARS,
7597
+ // 追加提示词“当前步骤结束后插入”等不到步骤边界时,退化成立即打断的等待时间
7598
+ agentStepFallbackMs: Number.isFinite(options.agentStepFallbackMs) && options.agentStepFallbackMs > 0
7599
+ ? options.agentStepFallbackMs
7600
+ : agentQueue.STEP_FALLBACK_MS,
7146
7601
  colors: options.colors || {
7147
7602
  GREEN: '',
7148
7603
  CYAN: '',
@@ -7426,6 +7881,10 @@ module.exports = {
7426
7881
  buildEnvModelOptions,
7427
7882
  // 仅供测试:直接驱动历史读写层
7428
7883
  __testing: {
7884
+ prepareStructuredTraceEvents,
7885
+ buildClaudeAgentExecCommand,
7886
+ extractContentDeltaFromPayload,
7887
+ extractAgentMessageFromStructuredOutput,
7429
7888
  appendWebSessionMessage,
7430
7889
  appendWebSessionControlEvent,
7431
7890
  removeWebSessionMessage,