@xcanwin/manyoyo 8.6.5 → 8.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
 
@@ -933,14 +940,26 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
933
940
  if (model) {
934
941
  flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
935
942
  }
943
+ // 子 Agent 说的话和思考默认不转发(v2.1.211+ 才有 --forward-subagent-text,旧版本不认这个参数会直接报错)。
944
+ // 'auto':在容器里随命令探测(claude --help 约 50ms),支持就展开成这个参数、不支持展开成空;true:直接加。
945
+ // 'auto' 的参数放在整条命令末尾,不改变前面已有参数的相对顺序
946
+ const forwardSubagentText = options && options.forwardSubagentText;
947
+ const templateHasForward = /(?:^|\s)--forward-subagent-text(?:\s|$)/.test(templateText);
948
+ if (forwardSubagentText === true) {
949
+ flagSpecs.push({ flag: '--forward-subagent-text', pattern: /(?:^|\s)--forward-subagent-text(?:\s|$)/ });
950
+ }
936
951
  const claudeTemplate = prependAgentFlags(
937
952
  templateText,
938
953
  /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/,
939
954
  flagSpecs
940
955
  );
941
- return claudeTemplate === templateText
956
+ const rendered = claudeTemplate === templateText
942
957
  ? renderAgentPromptCommand(templateText, prompt)
943
958
  : renderAgentPromptCommand(claudeTemplate, prompt);
959
+ if (forwardSubagentText === 'auto' && !templateHasForward) {
960
+ return `MANYOYO_FWD_SUBAGENT="$(claude --help 2>&1 | grep -q -- '--forward-subagent-text' && echo --forward-subagent-text)"; ${rendered} $MANYOYO_FWD_SUBAGENT`;
961
+ }
962
+ return rendered;
944
963
  }
945
964
 
946
965
  function buildGeminiAgentExecCommand(template, prompt, options = {}) {
@@ -1043,7 +1062,7 @@ function extractClaudeAgentMessage(text) {
1043
1062
  let lastMessage = '';
1044
1063
  for (const rawLine of String(text || '').split('\n')) {
1045
1064
  const payload = parseJsonObjectLine(rawLine);
1046
- if (!payload || payload.type !== 'assistant') {
1065
+ if (!payload || payload.type !== 'assistant' || pickFirstString(payload.parent_tool_use_id)) {
1047
1066
  continue;
1048
1067
  }
1049
1068
  const message = toPlainObject(payload.message);
@@ -1594,7 +1613,21 @@ function createStructuredTraceEvent(provider, kind, eventType, textValue, extra
1594
1613
  };
1595
1614
  }
1596
1615
 
1616
+ // 派生子 Agent 的工具:v2.1.63 起叫 Agent,旧名字 Task 仍是别名,两个都认
1617
+ const CLAUDE_SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
1618
+
1619
+ // 子 Agent 里的 assistant / user 消息带 parent_tool_use_id(发起它的那次 Agent 调用的 id),主对话里是 null。
1620
+ // 只给事件加 parentToolId 字段,不改已有字段的含义
1597
1621
  function prepareClaudeTraceEvents(payload, state) {
1622
+ const events = prepareClaudeTraceEventsBase(payload, state);
1623
+ const parentToolId = pickFirstString(payload && payload.parent_tool_use_id);
1624
+ if (parentToolId) {
1625
+ events.forEach(event => { event.parentToolId = parentToolId; });
1626
+ }
1627
+ return events;
1628
+ }
1629
+
1630
+ function prepareClaudeTraceEventsBase(payload, state) {
1598
1631
  const eventType = pickFirstString(payload.type);
1599
1632
  const subtype = pickFirstString(payload.subtype);
1600
1633
  const message = toPlainObject(payload.message);
@@ -1632,6 +1665,23 @@ function prepareClaudeTraceEvents(payload, state) {
1632
1665
  if (toolId) {
1633
1666
  toolNamesById.set(toolId, toolName);
1634
1667
  }
1668
+ if (CLAUDE_SUBAGENT_TOOL_NAMES.has(toolName)) {
1669
+ const input = toPlainObject(item.input);
1670
+ const subagentType = pickFirstString(input.subagent_type, input.name);
1671
+ const description = pickFirstString(input.description, input.name);
1672
+ events.push(createStructuredTraceEvent('claude', 'subagent', eventType, `[子 Agent] ${[subagentType, description].filter(Boolean).join(' ') || toolName}`, {
1673
+ phase: 'started',
1674
+ status: 'in_progress',
1675
+ toolName,
1676
+ toolId,
1677
+ subagentType,
1678
+ description,
1679
+ prompt: pickFirstString(input.prompt),
1680
+ arguments: input,
1681
+ argumentSummary: summarizeTraceArguments(input)
1682
+ }));
1683
+ return;
1684
+ }
1635
1685
  const summary = summarizeTraceArguments(toPlainObject(item.input));
1636
1686
  events.push(createStructuredTraceEvent(
1637
1687
  'claude',
@@ -1659,7 +1709,7 @@ function prepareClaudeTraceEvents(payload, state) {
1659
1709
  const toolId = pickFirstString(item.tool_use_id);
1660
1710
  const toolName = pickFirstString(toolNamesById.get(toolId), toolId, 'tool');
1661
1711
  const status = item.is_error === true ? 'error' : 'success';
1662
- events.push(createStructuredTraceEvent('claude', 'tool', eventType, `[工具完成] ${toolName} (${status})`, {
1712
+ events.push(createStructuredTraceEvent('claude', CLAUDE_SUBAGENT_TOOL_NAMES.has(toolName) ? 'subagent' : 'tool', eventType, `[${CLAUDE_SUBAGENT_TOOL_NAMES.has(toolName) ? '子 Agent 完成' : '工具完成'}] ${toolName} (${status})`, {
1663
1713
  phase: 'completed',
1664
1714
  status,
1665
1715
  toolName,
@@ -1867,6 +1917,9 @@ function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
1867
1917
  // 一轮里可能有多条 assistant 消息(正文→工具→正文),message_start 清空累计,
1868
1918
  // 语义与下面整条 assistant 消息的 reset 一致
1869
1919
  if (payloadType === 'stream_event') {
1920
+ if (pickFirstString(payload.parent_tool_use_id)) {
1921
+ return null;
1922
+ }
1870
1923
  const streamEvent = toPlainObject(payload.event);
1871
1924
  const streamEventType = pickFirstString(streamEvent.type);
1872
1925
  if (streamEventType === 'message_start') {
@@ -1890,6 +1943,10 @@ function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
1890
1943
  if (payloadType !== 'assistant') {
1891
1944
  return null;
1892
1945
  }
1946
+ // 子 Agent 说的话(--forward-subagent-text)只属于执行过程,不是主 Agent 的回复
1947
+ if (pickFirstString(payload.parent_tool_use_id)) {
1948
+ return null;
1949
+ }
1893
1950
  const message = toPlainObject(payload.message);
1894
1951
  const content = Array.isArray(message.content) ? message.content : [];
1895
1952
  const text = content
@@ -2215,7 +2272,8 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2215
2272
  : buildAgentPromptWithHistory(agentSession, prompt);
2216
2273
  const command = buildWebAgentExecCommand(effectiveTemplate, effectivePrompt, agentMeta.agentProgram, {
2217
2274
  sessionId: sessionIdForRun,
2218
- model: agentSession.model || ''
2275
+ model: agentSession.model || '',
2276
+ forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false
2219
2277
  });
2220
2278
  const contextMode = resumeSucceeded ? 'resume' : (hasPriorConversation ? 'history-injected' : 'first-turn');
2221
2279
 
@@ -3498,6 +3556,7 @@ async function startContainerServices(ctx, state) {
3498
3556
  const reconcile = () => reconcileContainerServices(ctx, state, manager);
3499
3557
  state.networkWatch = manager.watchStarts({ onReconnect: reconcile });
3500
3558
  await reconcile();
3559
+ resumeQueuedAgentTurns(ctx, state).catch(() => {});
3501
3560
  }
3502
3561
 
3503
3562
  // 拉起 autostartOnServe 的容器并恢复端口暴露:serve 启动时做一次,运行时重启后(事件流重连)再做一次,幂等
@@ -4151,7 +4210,8 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4151
4210
  process = spawnContainerExec(
4152
4211
  ctx,
4153
4212
  sessionRef.containerName,
4154
- { command: ['/bin/bash', '-lc', command] },
4213
+ // 包一层:新会话 + MANYOYO_RUN_ID + PID 文件,停止 / 打断时才能进到容器里按进程组发信号
4214
+ { command: ['/bin/bash', '-lc', agentRunControl.wrapAgentCommand(command, runState.runId)] },
4155
4215
  { stdio: ['ignore', 'pipe', 'pipe'] }
4156
4216
  );
4157
4217
  } catch (e) {
@@ -4166,11 +4226,7 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4166
4226
  state.agentRuns.set(sessionRef.containerName, runState);
4167
4227
  // 准备阶段(拉起容器等)用户就点了停止:进程刚 spawn 出来立刻收掉
4168
4228
  if (runState.stopping === true) {
4169
- try {
4170
- process.kill('SIGTERM');
4171
- } catch (e) {
4172
- // 进程可能已经退出,忽略
4173
- }
4229
+ terminateWebAgentRun(ctx, runState);
4174
4230
  }
4175
4231
 
4176
4232
  return await new Promise((resolve, reject) => {
@@ -4290,10 +4346,12 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4290
4346
 
4291
4347
  process.on('error', error => {
4292
4348
  releaseWebAgentRun(state, sessionRef.containerName, runState);
4349
+ runState.markClosed();
4293
4350
  reject(error);
4294
4351
  });
4295
4352
  process.on('close', code => {
4296
4353
  releaseWebAgentRun(state, sessionRef.containerName, runState);
4354
+ runState.markClosed();
4297
4355
  if (stdoutPending) {
4298
4356
  emitStdoutTraceLine(stdoutPending);
4299
4357
  stdoutPending = '';
@@ -4313,7 +4371,8 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
4313
4371
  exitCode,
4314
4372
  output,
4315
4373
  stdout: clippedStdout,
4316
- interrupted: exitCode !== 0 && runState.stopping === true
4374
+ // 真实的 claude 收到 SIGINT 会正常收尾、退出码为 0(L3 实测):发过信号就算被打断,不能只看退出码
4375
+ interrupted: runState.stopping === true && (exitCode !== 0 || runState.signaled === true)
4317
4376
  });
4318
4377
  });
4319
4378
  });
@@ -4361,15 +4420,16 @@ function readClientMessageId(res, payload) {
4361
4420
 
4362
4421
  // 同一个 clientMessageId 已经出现在该 agent 的消息里:这条提示词服务端已经收到过,不能再执行一遍
4363
4422
  // (前端在网络中断后重发同一条时,靠它兜住“服务端其实已经收到”的情况)
4364
- function rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId) {
4423
+ function rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId, options = {}) {
4365
4424
  if (!clientMessageId) {
4366
4425
  return false;
4367
4426
  }
4368
4427
  const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
4369
4428
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
4370
- const duplicated = Boolean(agentSession) && agentSession.messages.some(
4429
+ // includeQueue:排队中的也算收到过(追加请求网络中断后,前端把原文放回输入框再发,不能执行两次)
4430
+ const duplicated = Boolean(agentSession) && (agentSession.messages.some(
4371
4431
  message => message && message.role === 'user' && message.clientMessageId === clientMessageId
4372
- );
4432
+ ) || (options.includeQueue === true && agentQueue.getQueue(agentSession).some(item => item.clientMessageId === clientMessageId)));
4373
4433
  if (!duplicated) {
4374
4434
  return false;
4375
4435
  }
@@ -4385,7 +4445,7 @@ function claimClientMessage(res, webHistoryDir, sessionRef, clientMessageId) {
4385
4445
  if (!clientMessageId) {
4386
4446
  return () => {};
4387
4447
  }
4388
- if (rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId)) {
4448
+ if (rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId, { includeQueue: true })) {
4389
4449
  return null;
4390
4450
  }
4391
4451
  const key = `${path.resolve(webHistoryDir)}|${buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)}|${clientMessageId}`;
@@ -4502,12 +4562,18 @@ function resolveWebStreamControlEventType(event) {
4502
4562
  function reserveWebAgentRun(state, sessionRef) {
4503
4563
  const runState = {
4504
4564
  containerName: sessionRef.containerName,
4565
+ sessionRef: { containerName: sessionRef.containerName, agentId: sessionRef.agentId },
4505
4566
  sessionKey: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
4506
4567
  process: null,
4507
4568
  command: '',
4508
4569
  startedAt: new Date().toISOString(),
4509
- stopping: false
4570
+ stopping: false,
4571
+ // 进程 close 时 resolve:停止 / 打断时据此判断容器里的进程有没有退出(见 agent-run-control.js)
4572
+ runId: agentRunControl.createRunId(),
4573
+ closed: null,
4574
+ markClosed: null
4510
4575
  };
4576
+ runState.closed = new Promise(resolve => { runState.markClosed = resolve; });
4511
4577
  state.agentRuns.set(sessionRef.containerName, runState);
4512
4578
  return runState;
4513
4579
  }
@@ -4519,11 +4585,172 @@ function releaseWebAgentRun(state, containerName, runState) {
4519
4585
  }
4520
4586
  }
4521
4587
 
4522
- function stopWebAgentRun(state, containerName) {
4588
+ // 追加提示词时打断当前这一轮(立即 / 当前步骤结束后 / 步骤超时):和用户停止一样走进程组信号,
4589
+ // 区别是不置 userStopped,这一轮结束后会接着发队首
4590
+ function steerWebAgentRun(ctx, runState, reason, state = null) {
4591
+ if (!runState || runState.steered || runState.stopping) {
4592
+ return false;
4593
+ }
4594
+ // 排队的 now / step 项已被取消:不要白白打断正在跑的任务
4595
+ if (state && !queueHasSteerItems(state, runState.sessionRef)) {
4596
+ clearTimeout(runState.stepTimer);
4597
+ runState.stepPending = false;
4598
+ return false;
4599
+ }
4600
+ clearTimeout(runState.stepTimer);
4601
+ runState.steered = reason;
4602
+ runState.stopping = true;
4603
+ if (runState.process) {
4604
+ terminateWebAgentRun(ctx, runState);
4605
+ }
4606
+ return true;
4607
+ }
4608
+
4609
+ function queueHasSteerItems(state, sessionRef) {
4610
+ return peekAgentQueue(state, sessionRef).some(item => item.when === 'now' || item.when === 'step');
4611
+ }
4612
+
4613
+ function armStepSteer(ctx, state, runState) {
4614
+ if (runState.steered || runState.stopping || runState.stepPending) {
4615
+ return;
4616
+ }
4617
+ runState.stepPending = true;
4618
+ runState.stepTimer = setTimeout(() => {
4619
+ if (steerWebAgentRun(ctx, runState, 'step-timeout', state)) {
4620
+ ctx.logger.warn('agent steer step fallback to now', { session: runState.sessionKey });
4621
+ }
4622
+ }, ctx.agentStepFallbackMs || agentQueue.STEP_FALLBACK_MS);
4623
+ if (typeof runState.stepTimer.unref === 'function') {
4624
+ runState.stepTimer.unref();
4625
+ }
4626
+ }
4627
+
4628
+ // 没有前端连着时顶替 res:事件照常落盘,只是没人收
4629
+ function createHeadlessResponse() {
4630
+ const sink = new EventEmitter();
4631
+ sink.writableEnded = false;
4632
+ sink.writableFinished = false;
4633
+ sink.destroyed = false;
4634
+ sink.writeHead = () => {};
4635
+ sink.write = () => true;
4636
+ sink.end = () => {
4637
+ sink.writableEnded = true;
4638
+ sink.writableFinished = true;
4639
+ sink.emit('close');
4640
+ };
4641
+ return sink;
4642
+ }
4643
+
4644
+ function peekAgentSession(state, sessionRef) {
4645
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4646
+ return getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
4647
+ }
4648
+
4649
+ function peekAgentQueue(state, sessionRef) {
4650
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4651
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
4652
+ return agentSession && Array.isArray(agentSession.queue) ? agentSession.queue : [];
4653
+ }
4654
+
4655
+ // 同步取出队首并开始新一轮(取出和占锁之间没有 await,提示词不会丢也不会重复发)。
4656
+ // 开不起来(容器忙、准备失败)就放回队首,由用户决定
4657
+ // force:用户点了“继续发送”,解除暂停后发;其余(自动调度)遇到暂停的队列不发
4658
+ function dispatchNextQueued(ctx, state, sessionRef, { force = false } = {}) {
4659
+ if (state.agentRuns.has(sessionRef.containerName)) {
4660
+ return false;
4661
+ }
4662
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4663
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
4664
+ if (!agentSession || !agentQueue.getQueue(agentSession).length) {
4665
+ return false;
4666
+ }
4667
+ if (force) {
4668
+ agentQueue.resumeQueue(agentSession);
4669
+ } else if (agentQueue.isPaused(agentSession)) {
4670
+ return false;
4671
+ }
4672
+ const item = agentQueue.takeNext(agentSession);
4673
+ saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
4674
+ const putBack = reason => {
4675
+ const latest = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4676
+ const latestSession = getWebAgentSession(latest, sessionRef.agentId, { includeArchived: true });
4677
+ if (latestSession) {
4678
+ agentQueue.getQueue(latestSession).unshift(item);
4679
+ saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, latest);
4680
+ }
4681
+ ctx.logger.warn('queued prompt put back', { session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), reason });
4682
+ };
4683
+ runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse())
4684
+ .then(turn => {
4685
+ if (turn && turn.busy) putBack('busy');
4686
+ else if (turn && turn.error) putBack(turn.error);
4687
+ })
4688
+ .catch(e => putBack(e && e.message ? e.message : 'failed'));
4689
+ return true;
4690
+ }
4691
+
4692
+ // 容器里还活着的运行 id(上一个 serve 留下的孤儿任务,或另一个 serve 实例正在跑的任务);读不到当作没有
4693
+ function listLiveRunsInContainer(ctx, containerName) {
4694
+ return new Promise(resolve => {
4695
+ let child;
4696
+ try {
4697
+ child = spawnContainerExec(ctx, containerName, { command: agentRunControl.buildLiveRunsCommand() }, { stdio: ['ignore', 'pipe', 'ignore'] });
4698
+ } catch (e) {
4699
+ resolve([]);
4700
+ return;
4701
+ }
4702
+ let out = '';
4703
+ child.stdout.on('data', chunk => { out += chunk; });
4704
+ child.once('close', () => resolve(out.split('\n').map(line => line.trim()).filter(agentRunControl.isValidRunId)));
4705
+ child.once('error', () => resolve([]));
4706
+ });
4707
+ }
4708
+
4709
+ // serve 重启后:队列里还有内容(用户停止过的除外)、又没有在跑的任务,就当作“这一轮结束后发送”处理。
4710
+ // 容器里还有活着的 Agent 进程时不发:那是上一个 serve 留下的孤儿任务或另一个 serve 实例的任务,
4711
+ // 再开一轮会和它并发;排队保留,由用户在界面上“继续发送”
4712
+ async function resumeQueuedAgentTurns(ctx, state) {
4713
+ let containers = {};
4714
+ try {
4715
+ containers = listWebManyoyoContainers(ctx);
4716
+ } catch (e) {
4717
+ return;
4718
+ }
4719
+ for (const containerName of Object.keys(containers)) {
4720
+ const history = loadWebSessionHistory(state.webHistoryDir, containerName);
4721
+ const agentIds = Object.keys(history.agents || {}).filter(agentId => agentQueue.shouldAutoDispatch(history.agents[agentId]));
4722
+ if (!agentIds.length) {
4723
+ continue;
4724
+ }
4725
+ const live = await listLiveRunsInContainer(ctx, containerName);
4726
+ if (live.length) {
4727
+ ctx.logger.warn('queued prompts kept: agent still running in container', { container: containerName, runs: live.length });
4728
+ continue;
4729
+ }
4730
+ for (const agentId of agentIds) {
4731
+ dispatchNextQueued(ctx, state, { containerName, agentId });
4732
+ }
4733
+ }
4734
+ }
4735
+
4736
+ // signals:默认 INT → TERM → KILL(用户点停止);ctx 缺省时退回只杀宿主机上的客户端
4737
+ function stopWebAgentRun(state, containerName, ctx = null) {
4523
4738
  const runState = state.agentRuns.get(containerName);
4524
4739
  if (!runState) {
4525
4740
  return false;
4526
4741
  }
4742
+ // 用户主动停止:排队的提示词不再自动发送(队列保留,由用户决定取消或继续)。
4743
+ // 暂停要落盘:只记在这一轮的内存里的话,serve 重启或下一轮结束后会被自动发出去
4744
+ runState.userStopped = true;
4745
+ if (runState.sessionRef) {
4746
+ const history = loadWebSessionHistory(state.webHistoryDir, runState.sessionRef.containerName);
4747
+ const agentSession = getWebAgentSession(history, runState.sessionRef.agentId, { includeArchived: true });
4748
+ if (agentSession && agentQueue.getQueue(agentSession).length && !agentQueue.isPaused(agentSession)) {
4749
+ agentQueue.pauseQueue(agentSession);
4750
+ saveWebSessionHistory(state.webHistoryDir, runState.sessionRef.containerName, history);
4751
+ }
4752
+ }
4753
+ clearTimeout(runState.stepTimer);
4527
4754
  if (!runState.process) {
4528
4755
  // 还在准备阶段(拉起容器 / 探测 resume),进程尚未 spawn:先记下停止意图,
4529
4756
  // execAgentInWebContainerStream 拿到进程后会立刻收掉它
@@ -4534,14 +4761,42 @@ function stopWebAgentRun(state, containerName) {
4534
4761
  return false;
4535
4762
  }
4536
4763
  runState.stopping = true;
4537
- try {
4538
- runState.process.kill('SIGTERM');
4539
- } catch (e) {
4540
- // 进程可能已自行退出,视为停止成功(close 事件会收尾)
4764
+ if (!ctx) {
4765
+ try {
4766
+ runState.process.kill('SIGTERM');
4767
+ } catch (e) {
4768
+ // 进程可能已自行退出,视为停止成功(close 事件会收尾)
4769
+ }
4770
+ return true;
4541
4771
  }
4772
+ terminateWebAgentRun(ctx, runState);
4542
4773
  return true;
4543
4774
  }
4544
4775
 
4776
+ // 进到容器里按进程组停:只杀宿主机上的 exec 客户端,容器里的进程会继续跑(实测)
4777
+ function terminateWebAgentRun(ctx, runState) {
4778
+ if (runState.terminating) {
4779
+ return runState.terminating;
4780
+ }
4781
+ runState.terminating = agentRunControl.terminateRun({
4782
+ runState,
4783
+ graceMs: ctx.agentStopGraceMs || agentRunControl.DEFAULT_GRACE_MS,
4784
+ killInContainer: (runId, signal) => new Promise(resolve => {
4785
+ runState.signaled = true;
4786
+ let child;
4787
+ try {
4788
+ child = spawnContainerExec(ctx, runState.containerName, { command: agentRunControl.buildRunKillCommand(runId, signal) }, { stdio: 'ignore' });
4789
+ } catch (e) {
4790
+ resolve();
4791
+ return;
4792
+ }
4793
+ child.once('close', resolve);
4794
+ child.once('error', resolve);
4795
+ })
4796
+ }).catch(() => {});
4797
+ return runState.terminating;
4798
+ }
4799
+
4545
4800
  // serve 重启等原因会丢失 agentRuns 运行登记,但消息里可能残留 pending 标记
4546
4801
  // (孤儿任务:容器里的进程或许还在跑,但再没有任何代码会去收尾这些消息)。
4547
4802
  // 前端把 pending 视为"任务进行中"会永久禁用输入,这里把它们标记为已中断
@@ -4568,6 +4823,310 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
4568
4823
  saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
4569
4824
  }
4570
4825
 
4826
+ // 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
4827
+ // 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
4828
+ // 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
4829
+ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res) {
4830
+ if (state.agentRuns.has(sessionRef.containerName)) {
4831
+ // 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
4832
+ return { busy: true };
4833
+ }
4834
+ // 紧接着检查同步占位,中间不能有 await,否则并发请求会同时穿过上面这道检查
4835
+ const runState = reserveWebAgentRun(state, sessionRef);
4836
+
4837
+ let userMessage;
4838
+ let traceMessage;
4839
+ try {
4840
+ userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
4841
+ mode: 'agent',
4842
+ pending: true,
4843
+ ...(clientMessageId ? { clientMessageId: clientMessageId } : {})
4844
+ });
4845
+ traceMessage = appendWebAgentTraceMessage(
4846
+ state.webHistoryDir,
4847
+ sessionRef,
4848
+ '[执行过程]\n等待 Agent 启动…',
4849
+ {
4850
+ traceEvents: [],
4851
+ pending: true
4852
+ }
4853
+ );
4854
+ } catch (e) {
4855
+ // 落盘失败(磁盘错误等):锁必须放掉,否则这个容器永远发不出消息
4856
+ releaseWebAgentRun(state, sessionRef.containerName, runState);
4857
+ throw e;
4858
+ }
4859
+
4860
+ let prepared = null;
4861
+ try {
4862
+ prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
4863
+ } catch (e) {
4864
+ releaseWebAgentRun(state, sessionRef.containerName, runState);
4865
+ removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
4866
+ removeWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id);
4867
+ return { error: e && e.message ? e.message : 'Agent 执行准备失败' };
4868
+ }
4869
+
4870
+ const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
4871
+ const traceLines = ['[执行过程]'];
4872
+ const traceEvents = [];
4873
+ let streamingReplyMessageId = '';
4874
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
4875
+ pending: true,
4876
+ contextMode
4877
+ });
4878
+
4879
+ res.writeHead(200, {
4880
+ 'Content-Type': 'application/x-ndjson; charset=utf-8',
4881
+ 'Cache-Control': 'no-store',
4882
+ 'X-Accel-Buffering': 'no'
4883
+ });
4884
+ // agent 流的生命周期日志:每轮最多三条(开始 / 客户端断开 / 结束),
4885
+ // 不是逐事件打点。没有这几条的话,用户报"跑到一半断流了"时服务端
4886
+ // 日志里查不到任何痕迹——断连本身只发生在客户端侧,服务端不主动感知
4887
+ const turnStartedAt = Date.now();
4888
+ const turnSessionKey = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
4889
+ ctx.logger.info('agent turn started', {
4890
+ session: turnSessionKey,
4891
+ agentProgram: agentMeta.agentProgram,
4892
+ contextMode,
4893
+ resumeAttempted,
4894
+ resumeSucceeded
4895
+ });
4896
+ // 'close' 先于 'finish' 触发且 writableFinished 仍为 false,说明是
4897
+ // 客户端/反代把连接掐了,而不是我们正常收尾。此时容器里的任务还在跑,
4898
+ // 前端会转入恢复轮询——日志要把这个区别写清楚,否则事后无法归因
4899
+ res.on('close', () => {
4900
+ if (res.writableFinished) {
4901
+ return;
4902
+ }
4903
+ ctx.logger.warn('agent stream client disconnected', {
4904
+ session: turnSessionKey,
4905
+ elapsedMs: Date.now() - turnStartedAt,
4906
+ runContinues: state.agentRuns.has(sessionRef.containerName)
4907
+ });
4908
+ });
4909
+ // agent 可能几十秒不产生任何输出(长 WebSearch、长 Bash、长思考),
4910
+ // 而反向代理按"上游多久没发字节"计空闲超时(nginx proxy_read_timeout
4911
+ // 默认 60s),超时会 RST 掉这条流,浏览器侧抛 network error。这里定期
4912
+ // 发一行 ping 保活;前端忽略该事件,也不写入会话事件日志
4913
+ const heartbeatMs = ctx.agentStreamHeartbeatMs || DEFAULT_AGENT_STREAM_HEARTBEAT_MS;
4914
+ let lastStreamWriteAt = Date.now();
4915
+ const heartbeatTimer = setInterval(() => {
4916
+ if (res.writableEnded || res.destroyed) {
4917
+ return;
4918
+ }
4919
+ if (Date.now() - lastStreamWriteAt < heartbeatMs) {
4920
+ return;
4921
+ }
4922
+ sendNdjson(res, { type: 'ping' });
4923
+ lastStreamWriteAt = Date.now();
4924
+ }, Math.max(50, Math.floor(heartbeatMs / 2)));
4925
+ if (typeof heartbeatTimer.unref === 'function') {
4926
+ heartbeatTimer.unref();
4927
+ }
4928
+ const rawEmitStreamEvent = createWebStreamEmitter(res, state.webHistoryDir, sessionRef);
4929
+ const emitStreamEvent = (...args) => {
4930
+ lastStreamWriteAt = Date.now();
4931
+ return rawEmitStreamEvent(...args);
4932
+ };
4933
+ emitStreamEvent({
4934
+ type: 'meta',
4935
+ containerName: sessionRef.containerName,
4936
+ sessionName: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
4937
+ // 前端用这两个 id 把本地乐观占位(local-* / __streaming_trace__)换成
4938
+ // 服务端持久化 id,轮询对账时 React key 才能对得上、DOM 不整体重建
4939
+ userMessageId: userMessage && userMessage.id,
4940
+ traceMessageId: traceMessage && traceMessage.id,
4941
+ contextMode,
4942
+ resumeAttempted,
4943
+ resumeSucceeded,
4944
+ agentProgram: agentMeta.agentProgram
4945
+ }, 'session.ready', { agentProgram: agentMeta.agentProgram });
4946
+ if (contextMode) {
4947
+ traceLines.push(`上下文模式: ${contextMode}`);
4948
+ }
4949
+ if (resumeAttempted) {
4950
+ traceLines.push(resumeSucceeded ? '会话恢复成功' : '会话恢复失败,已回退到历史注入');
4951
+ }
4952
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
4953
+ content: traceLines.join('\n'),
4954
+ traceEvents: traceEvents.slice(),
4955
+ contextMode,
4956
+ resumeAttempted,
4957
+ resumeSucceeded,
4958
+ pending: true
4959
+ });
4960
+
4961
+ let lastPartialPersistAt = 0;
4962
+ const handleStreamEvent = event => {
4963
+ if (runState.stepPending && agentQueue.isStepBoundary(event)) {
4964
+ steerWebAgentRun(ctx, runState, 'step', state);
4965
+ }
4966
+ // token 级增量:只走网线,不进事件日志,历史落盘按
4967
+ // AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
4968
+ const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
4969
+ if (event && event.type === 'trace' && event.text) {
4970
+ traceLines.push(String(event.text));
4971
+ if (event.traceEvent && typeof event.traceEvent === 'object') {
4972
+ traceEvents.push(event.traceEvent);
4973
+ }
4974
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
4975
+ content: traceLines.join('\n'),
4976
+ traceEvents: traceEvents.slice(),
4977
+ pending: true
4978
+ }, { defer: true });
4979
+ }
4980
+ if (event && event.type === 'content_delta' && typeof event.content === 'string') {
4981
+ const now = Date.now();
4982
+ const shouldPersist = !isPartialDelta
4983
+ || now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
4984
+ if (!streamingReplyMessageId) {
4985
+ const streamingReplyMessage = appendWebSessionMessage(
4986
+ state.webHistoryDir,
4987
+ sessionRef,
4988
+ 'assistant',
4989
+ event.content,
4990
+ {
4991
+ mode: 'agent',
4992
+ streamingReply: true,
4993
+ pending: true
4994
+ }
4995
+ );
4996
+ streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
4997
+ ? streamingReplyMessage.id
4998
+ : '';
4999
+ lastPartialPersistAt = now;
5000
+ } else if (shouldPersist) {
5001
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
5002
+ content: event.content,
5003
+ pending: true
5004
+ }, { defer: true });
5005
+ lastPartialPersistAt = now;
5006
+ }
5007
+ }
5008
+ if (isPartialDelta) {
5009
+ lastStreamWriteAt = Date.now();
5010
+ // 只发增量片段:前端在 content_chunk 上做追加,
5011
+ // reset 表示换了一条 assistant 消息、从空白重新开始
5012
+ sendNdjson(res, event.chunkReset === true
5013
+ ? { type: 'content_chunk', text: '', reset: true }
5014
+ : { type: 'content_chunk', text: event.chunk });
5015
+ return;
5016
+ }
5017
+ emitStreamEvent(event, resolveWebStreamControlEventType(event), {
5018
+ transportType: event && event.type ? event.type : '',
5019
+ text: event && (event.text || event.content) ? String(event.text || event.content) : ''
5020
+ });
5021
+
5022
+ };
5023
+ // 有域名规则时被过滤代理拦下的新域名:对话进行中立即提示一次
5024
+ const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }));
5025
+ try {
5026
+ if (deniedNotifier) deniedNotifier.start();
5027
+ let result;
5028
+ try {
5029
+ result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
5030
+ agentProgram: agentMeta.agentProgram,
5031
+ // 复用路由入口占好的那把锁,而不是另起一份运行登记
5032
+ runState,
5033
+ onEvent: handleStreamEvent
5034
+ });
5035
+ } finally {
5036
+ if (deniedNotifier) deniedNotifier.stop();
5037
+ }
5038
+ ctx.logger.info('agent turn finished', {
5039
+ session: turnSessionKey,
5040
+ durationMs: Date.now() - turnStartedAt,
5041
+ exitCode: result.exitCode,
5042
+ interrupted: result.interrupted === true
5043
+ });
5044
+ traceLines.push(result.interrupted === true
5045
+ ? (runState.steered && runState.userStopped !== true ? '[任务] 已打断,继续执行新的提示词' : '[任务] 已停止')
5046
+ : '[任务] 已完成');
5047
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
5048
+ pending: false,
5049
+ contextMode,
5050
+ resumeAttempted,
5051
+ resumeSucceeded
5052
+ });
5053
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5054
+ content: traceLines.join('\n'),
5055
+ traceEvents,
5056
+ contextMode,
5057
+ resumeAttempted,
5058
+ resumeSucceeded,
5059
+ interrupted: result.interrupted === true,
5060
+ pending: false
5061
+ });
5062
+ if (streamingReplyMessageId) {
5063
+ removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
5064
+ }
5065
+ const replyMessage = finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
5066
+ contextMode,
5067
+ resumeAttempted,
5068
+ resumeSucceeded,
5069
+ resumeError,
5070
+ engineSessionId
5071
+ }, result);
5072
+ emitStreamEvent({
5073
+ type: 'result',
5074
+ exitCode: result.exitCode,
5075
+ output: result.output,
5076
+ // 最终 assistant 消息的持久化 id:前端把 __streaming__ 占位换成它,
5077
+ // 发送后的首次对账才不会重建整个消息列表 DOM(滚动位置/选区保持)
5078
+ replyMessageId: replyMessage && replyMessage.id,
5079
+ contextMode,
5080
+ resumeAttempted,
5081
+ resumeSucceeded,
5082
+ interrupted: result.interrupted === true
5083
+ }, result.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
5084
+ } catch (e) {
5085
+ ctx.logger.error('agent turn failed', {
5086
+ session: turnSessionKey,
5087
+ durationMs: Date.now() - turnStartedAt,
5088
+ message: e && e.message ? e.message : 'Agent 执行失败'
5089
+ });
5090
+ traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
5091
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
5092
+ pending: false,
5093
+ contextMode,
5094
+ resumeAttempted,
5095
+ resumeSucceeded
5096
+ });
5097
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5098
+ content: traceLines.join('\n'),
5099
+ traceEvents,
5100
+ contextMode,
5101
+ resumeAttempted,
5102
+ resumeSucceeded,
5103
+ interrupted: true,
5104
+ pending: false
5105
+ });
5106
+ if (streamingReplyMessageId) {
5107
+ removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
5108
+ }
5109
+ emitStreamEvent({
5110
+ type: 'error',
5111
+ error: e && e.message ? e.message : 'Agent 执行失败'
5112
+ }, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
5113
+ } finally {
5114
+ clearInterval(heartbeatTimer);
5115
+ clearTimeout(runState.stepTimer);
5116
+ // 用户停止的不自动发;其余(完成 / 失败 / 被追加打断)有排队就接着发队首
5117
+ const hasNext = runState.userStopped !== true && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
5118
+ if (hasNext && !res.writableEnded && !res.destroyed) {
5119
+ // 前端收到后改为跟随下一轮(走掉线恢复的那套轮询)
5120
+ sendNdjson(res, { type: 'queue_next' });
5121
+ }
5122
+ res.end();
5123
+ if (hasNext) {
5124
+ setImmediate(() => dispatchNextQueued(ctx, state, sessionRef));
5125
+ }
5126
+ }
5127
+ return { started: true };
5128
+ }
5129
+
4571
5130
  function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
4572
5131
  sendBody(res, statusCode, 'text/html; charset=utf-8', html, extraHeaders, options);
4573
5132
  }
@@ -6143,6 +6702,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6143
6702
  name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
6144
6703
  containerName: sessionRef.containerName,
6145
6704
  agentId: sessionRef.agentId,
6705
+ queue: agentQueue.publicQueue(agentSession),
6706
+ queuePaused: agentQueue.isPaused(agentSession),
6146
6707
  ...view
6147
6708
  }, {}, { etag });
6148
6709
  }
@@ -6689,285 +7250,109 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6689
7250
  if (!streamClientId) {
6690
7251
  return;
6691
7252
  }
6692
- if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value)) {
7253
+ if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
6693
7254
  return;
6694
7255
  }
6695
- if (state.agentRuns.has(sessionRef.containerName)) {
6696
- // 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
7256
+ const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res);
7257
+ if (turn.busy) {
6697
7258
  sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
7259
+ } else if (turn.error) {
7260
+ sendJson(res, 400, { error: turn.error });
7261
+ }
7262
+ }
7263
+ },
7264
+ {
7265
+ method: 'POST',
7266
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/queue$/),
7267
+ handler: async match => {
7268
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7269
+ if (!sessionRef) {
6698
7270
  return;
6699
7271
  }
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 执行准备失败' });
7272
+ const payload = await readJsonBody(req);
7273
+ const text = (payload.prompt || '').trim();
7274
+ if (!text) {
7275
+ sendJson(res, 400, { error: 'prompt 不能为空' });
6726
7276
  return;
6727
7277
  }
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}`);
7278
+ const when = agentQueue.normalizeWhen(payload.when === undefined ? 'turn' : payload.when);
7279
+ if (!when) {
7280
+ sendJson(res, 400, { error: 'when 只能是 now、step 或 turn' });
7281
+ return;
6807
7282
  }
6808
- if (resumeAttempted) {
6809
- traceLines.push(resumeSucceeded ? '会话恢复成功' : '会话恢复失败,已回退到历史注入');
7283
+ const queueClientId = readClientMessageId(res, payload);
7284
+ if (!queueClientId) {
7285
+ return;
6810
7286
  }
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);
7287
+ if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, queueClientId.value)) {
7288
+ return;
7289
+ }
7290
+ const runState = state.agentRuns.get(sessionRef.containerName);
7291
+ if (runState && runState.sessionKey !== buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)) {
7292
+ sendJson(res, 409, { error: '当前容器已有其他会话的 agent 任务在运行,请等它结束或先停止' });
7293
+ return;
7294
+ }
7295
+ // 入队与下面的调度之间没有 await:队列落盘和打断判断对同一会话是原子的
7296
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
7297
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
7298
+ const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when });
7299
+ if (!added.ok) {
7300
+ sendJson(res, 409, { error: '排队已满(最多 10 条),请先取消几条或等它们发出', full: true });
7301
+ return;
7302
+ }
7303
+ if (!added.duplicate) {
7304
+ saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
7305
+ if (!runState) {
7306
+ dispatchNextQueued(ctx, state, sessionRef);
7307
+ } else if (when === 'now') {
7308
+ steerWebAgentRun(ctx, runState, 'now', state);
7309
+ } else if (when === 'step') {
7310
+ armStepSteer(ctx, state, runState);
6962
7311
  }
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
7312
  }
7313
+ sendJson(res, 200, { item: added.item, duplicate: added.duplicate, queue: agentQueue.publicQueue(agentSession) });
7314
+ }
7315
+ },
7316
+ {
7317
+ method: 'DELETE',
7318
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/queue\/([A-Za-z0-9_-]+)$/),
7319
+ handler: async match => {
7320
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7321
+ if (!sessionRef) {
7322
+ return;
7323
+ }
7324
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
7325
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
7326
+ const removed = agentSession ? agentQueue.removeFromQueue(agentSession, match[2]) : null;
7327
+ if (!removed) {
7328
+ // 已经发出去了或早被取消:前端据此刷新,不当错误
7329
+ sendJson(res, 404, { error: '这条排队的提示词已经发出或已取消' });
7330
+ return;
7331
+ }
7332
+ saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
7333
+ // 取消的是最后一个 now / step:撤掉还在等的“步骤结束后打断”
7334
+ const activeRun = state.agentRuns.get(sessionRef.containerName);
7335
+ if (activeRun && activeRun.stepPending && !queueHasSteerItems(state, sessionRef)) {
7336
+ clearTimeout(activeRun.stepTimer);
7337
+ activeRun.stepPending = false;
7338
+ }
7339
+ sendJson(res, 200, { removed: removed.id, queue: agentQueue.publicQueue(agentSession) });
7340
+ }
7341
+ },
7342
+ {
7343
+ method: 'POST',
7344
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/queue\/continue$/),
7345
+ handler: async match => {
7346
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
7347
+ if (!sessionRef) {
7348
+ return;
7349
+ }
7350
+ if (state.agentRuns.has(sessionRef.containerName)) {
7351
+ sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务' });
7352
+ return;
7353
+ }
7354
+ const started = dispatchNextQueued(ctx, state, sessionRef, { force: true });
7355
+ sendJson(res, 200, { started });
6971
7356
  }
6972
7357
  },
6973
7358
  {
@@ -6978,7 +7363,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
6978
7363
  if (!sessionRef) {
6979
7364
  return;
6980
7365
  }
6981
- const stopped = stopWebAgentRun(state, sessionRef.containerName);
7366
+ const stopped = stopWebAgentRun(state, sessionRef.containerName, ctx);
6982
7367
  if (!stopped) {
6983
7368
  // 运行登记不存在(serve 重启后常见的孤儿任务):把残留的
6984
7369
  // pending 消息标记为已中断,否则前端会因 pending 永久禁用输入
@@ -7143,6 +7528,14 @@ async function startWebServer(options) {
7143
7528
  agentStreamHeartbeatMs: Number.isFinite(options.agentStreamHeartbeatMs) && options.agentStreamHeartbeatMs > 0
7144
7529
  ? options.agentStreamHeartbeatMs
7145
7530
  : DEFAULT_AGENT_STREAM_HEARTBEAT_MS,
7531
+ // 停止 / 打断时每一级信号(INT → TERM → KILL)等进程退出的时间
7532
+ agentStopGraceMs: Number.isFinite(options.agentStopGraceMs) && options.agentStopGraceMs > 0
7533
+ ? options.agentStopGraceMs
7534
+ : agentRunControl.DEFAULT_GRACE_MS,
7535
+ // 追加提示词“当前步骤结束后插入”等不到步骤边界时,退化成立即打断的等待时间
7536
+ agentStepFallbackMs: Number.isFinite(options.agentStepFallbackMs) && options.agentStepFallbackMs > 0
7537
+ ? options.agentStepFallbackMs
7538
+ : agentQueue.STEP_FALLBACK_MS,
7146
7539
  colors: options.colors || {
7147
7540
  GREEN: '',
7148
7541
  CYAN: '',
@@ -7426,6 +7819,10 @@ module.exports = {
7426
7819
  buildEnvModelOptions,
7427
7820
  // 仅供测试:直接驱动历史读写层
7428
7821
  __testing: {
7822
+ prepareStructuredTraceEvents,
7823
+ buildClaudeAgentExecCommand,
7824
+ extractContentDeltaFromPayload,
7825
+ extractAgentMessageFromStructuredOutput,
7429
7826
  appendWebSessionMessage,
7430
7827
  appendWebSessionControlEvent,
7431
7828
  removeWebSessionMessage,