@xcanwin/manyoyo 8.6.6 → 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/AGENTS.md +4 -0
- package/lib/web/agent-queue.js +150 -0
- package/lib/web/agent-run-control.js +124 -0
- package/lib/web/index.html +80 -80
- package/lib/web/server.js +689 -292
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
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
|
-
|
|
6696
|
-
|
|
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
|
-
|
|
6701
|
-
const
|
|
6702
|
-
|
|
6703
|
-
|
|
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
|
-
|
|
6730
|
-
|
|
6731
|
-
|
|
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
|
-
|
|
6809
|
-
|
|
7283
|
+
const queueClientId = readClientMessageId(res, payload);
|
|
7284
|
+
if (!queueClientId) {
|
|
7285
|
+
return;
|
|
6810
7286
|
}
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
}
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
const
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
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,
|