chatccc 0.2.282 → 0.2.284
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/README.md +5 -1
- package/deepccc-agent/README.md +2 -0
- package/deepccc-agent/package.json +1 -1
- package/dist/deepccc-agent/src/index.js +17 -0
- package/dist/src/adapters/ccc-adapter.js +9 -0
- package/dist/src/adapters/claude-adapter.js +21 -1
- package/dist/src/adapters/codex-adapter.js +52 -13
- package/dist/src/adapters/cursor-adapter.js +49 -10
- package/dist/src/adapters/dsh-adapter.js +7 -11
- package/dist/src/adapters/managed-cli-process.js +43 -0
- package/dist/src/adapters/proc-tree-kill.js +86 -85
- package/dist/src/adapters/turn-completion.js +25 -0
- package/dist/src/agent-activity.js +6 -1
- package/dist/src/session.js +16 -33
- package/dist/src/terminal-error.js +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -449,9 +449,13 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
449
449
|
|
|
450
450
|
ChatCCC 的内部重启和更新使用跨平台父子进程握手:替代进程完成启动预检后通知父进程退出,再等待旧监听端口实际释放并接管 PID;替代进程未就绪或握手超时时,父进程会保留并继续服务。
|
|
451
451
|
|
|
452
|
+
Codex 和 Cursor 在 Linux/macOS 下使用独立进程组。停止时先显示“正在停止”,待进程组内后代退出后再显示“已停止”;仅外层 shell 退出不算清理完成。重复停止请求合并处理,清理失败会保留会话进程占用保护并报告“Agent 停止未完成”,后续请求必须先完成清理才能启动新 Agent。Windows 继续使用 `taskkill /T /F`,命令失败或超时不会当作成功。
|
|
453
|
+
|
|
452
454
|
飞书接收长连接启用 15 秒握手超时和 30 秒心跳应答超时;首次启动最多等待 45 秒真实连接确认后才提示就绪。运行期间连接持续异常 90 秒时,会关闭并重新建立接收连接,保留会话、消息去重和正在执行的任务。没有用户消息不会触发重连。连接状态与恢复记录写入运行日志和 `startup-trace.log`(`FEISHU-CONNECTION` / `feishu-connection`)。卡片更新遇到网络失败或序号冲突时不会当成送达成功,错误通知保留文本兜底;TLS 断线会明确显示为网络连接失败,已中断的 Agent 任务不会因此自动重放。
|
|
453
455
|
|
|
454
|
-
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`;Cursor、Codex、CCC Agent 和 DSH 使用各自的 `model` / `alternativeModel`。
|
|
456
|
+
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`;Cursor、Codex、CCC Agent 和 DSH 使用各自的 `model` / `alternativeModel`。
|
|
457
|
+
|
|
458
|
+
Agent 的初始化、输入回显与重连通知不代表任务成功。Cursor、Codex 和 Claude 缺少成功完成事件,或任一 Agent 没有产生有效回复时,会明确提示异常;已生成的部分回复会保留并标为可能不完整。Cursor 重连过程显示在状态区,上游的 `resource_exhausted` / `unavailable` 分别提示请求受限 / 服务暂不可用,不推断为余额耗尽。执行失败不会自动重放整条任务。
|
|
455
459
|
|
|
456
460
|
> **Codex Fast 模式**:Web UI 中的“Fast 模式”设置新 Codex 会话的全局默认值,默认关闭。进入 Codex 会话后,`/fast` 查询当前状态,`/fast on` 和 `/fast off` 只覆盖当前会话并从下一条消息生效。ChatCCC 会显式向 Codex CLI 传入 `service_tier="fast"` 或 `service_tier="default"`,因此关闭时不会继承用户 `config.toml` 中可能开启的 Fast。
|
|
457
461
|
|
package/deepccc-agent/README.md
CHANGED
|
@@ -478,16 +478,25 @@ export class ChatSession {
|
|
|
478
478
|
messages: attemptMessages,
|
|
479
479
|
};
|
|
480
480
|
let stream;
|
|
481
|
+
let requiresFinish = false;
|
|
482
|
+
let receivedFinish = false;
|
|
483
|
+
let finishReason;
|
|
481
484
|
if (this.streaming) {
|
|
482
485
|
const result = streamText(generationOptions);
|
|
486
|
+
requiresFinish = result.fullStream != null;
|
|
483
487
|
stream = result.fullStream ?? textStreamToFullStream(result.textStream);
|
|
484
488
|
}
|
|
485
489
|
else {
|
|
486
490
|
const result = await generateText(generationOptions);
|
|
491
|
+
finishReason = result.finishReason;
|
|
487
492
|
stream = generateResultToFullStream(result);
|
|
488
493
|
}
|
|
489
494
|
for await (const part of stream) {
|
|
490
495
|
rawLog?.writeLine(safeRawStreamJson(part));
|
|
496
|
+
if (part.type === "finish") {
|
|
497
|
+
receivedFinish = true;
|
|
498
|
+
finishReason = part.finishReason;
|
|
499
|
+
}
|
|
491
500
|
if (part.type === "reasoning-start" || part.type === "reasoning-delta") {
|
|
492
501
|
// Reasoning content remains private. A throttled heartbeat is enough
|
|
493
502
|
// for ChatCCC to distinguish active inference from a stalled stream.
|
|
@@ -561,6 +570,14 @@ export class ChatSession {
|
|
|
561
570
|
throw new Error(message);
|
|
562
571
|
}
|
|
563
572
|
}
|
|
573
|
+
if (!signal?.aborted) {
|
|
574
|
+
if (requiresFinish && !receivedFinish)
|
|
575
|
+
throw new Error("DeepCCC 输出流中断:未收到模型完成事件,回复可能不完整");
|
|
576
|
+
if (finishReason === "error" || finishReason === "length")
|
|
577
|
+
throw new Error(`DeepCCC 未正常完成:finishReason=${finishReason},回复可能不完整`);
|
|
578
|
+
if (!fullText.trim() && toolCallOrder.length === 0)
|
|
579
|
+
throw new Error("DeepCCC 本轮未产生有效回复");
|
|
580
|
+
}
|
|
564
581
|
if (hasMalformedToolProtocolText(fullText)) {
|
|
565
582
|
console.warn(`[DeepCCC] malformed tool protocol text detected for ${this.context.sessionId} `
|
|
566
583
|
+ `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
|
|
@@ -2,6 +2,7 @@ import { ChatSession } from "../../deepccc-agent/src/index.js";
|
|
|
2
2
|
import { config as deepCccConfig } from "../../deepccc-agent/src/config.js";
|
|
3
3
|
import { getBuiltinContextSession, newBuiltinSessionId, normalizeBuiltinSessionId, } from "../../deepccc-agent/src/context.js";
|
|
4
4
|
import { config, CCC_SESSION_PREFIX } from "../config.js";
|
|
5
|
+
import { createTurnCompletion } from "./turn-completion.js";
|
|
5
6
|
function toChatSessionOptions(sessionId, cwd, options) {
|
|
6
7
|
return {
|
|
7
8
|
cwd,
|
|
@@ -50,7 +51,12 @@ export function createCccAdapter(options = {}) {
|
|
|
50
51
|
async *prompt(sessionId, userText, cwd, signal, _promptOptions) {
|
|
51
52
|
const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
|
|
52
53
|
const session = new ChatSession(chatConfig, toChatSessionOptions(normalizedSessionId, cwd, options));
|
|
54
|
+
const completion = createTurnCompletion("CCC Agent");
|
|
53
55
|
for await (const event of session.chat(userText, signal)) {
|
|
56
|
+
if (event.type === "text")
|
|
57
|
+
completion.observe({ type: "assistant", blocks: [{ type: "text", text: event.text }] });
|
|
58
|
+
if (event.type === "text_reset")
|
|
59
|
+
completion.observe({ type: "assistant", blocks: [{ type: "text_reset" }] });
|
|
54
60
|
if (event.type === "status") {
|
|
55
61
|
yield {
|
|
56
62
|
type: "assistant",
|
|
@@ -101,6 +107,7 @@ export function createCccAdapter(options = {}) {
|
|
|
101
107
|
};
|
|
102
108
|
}
|
|
103
109
|
else if (event.type === "done" && !signal?.aborted) {
|
|
110
|
+
completion.complete();
|
|
104
111
|
yield {
|
|
105
112
|
type: "assistant",
|
|
106
113
|
blocks: [],
|
|
@@ -111,6 +118,8 @@ export function createCccAdapter(options = {}) {
|
|
|
111
118
|
throw new Error(event.message);
|
|
112
119
|
}
|
|
113
120
|
}
|
|
121
|
+
if (!signal?.aborted)
|
|
122
|
+
completion.assertComplete();
|
|
114
123
|
},
|
|
115
124
|
async getSessionInfo(sessionId) {
|
|
116
125
|
const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
|
|
@@ -11,6 +11,8 @@ import { homedir } from "node:os";
|
|
|
11
11
|
import { delimiter, join } from "node:path";
|
|
12
12
|
import { pathToFileURL } from "node:url";
|
|
13
13
|
import { parseUserCommand } from "./adapter-interface.js";
|
|
14
|
+
import { createTurnCompletion } from "./turn-completion.js";
|
|
15
|
+
import { sanitizeTerminalErrorDetail } from "../terminal-error.js";
|
|
14
16
|
import { CHATCCC_PORT, config, PROJECT_ROOT, RAW_STREAM_LOGS_DIR } from "../config.js";
|
|
15
17
|
import { defaultClaudeSessionMetaStore, } from "./claude-session-meta-store.js";
|
|
16
18
|
import { createRawStreamLog, } from "./raw-stream-log.js";
|
|
@@ -127,6 +129,9 @@ function logMcpConfig() {
|
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
131
|
export function normalizeSdkMessage(msg) {
|
|
132
|
+
if (msg.type === "result" && (msg.subtype !== "success" || msg.is_error === true)) {
|
|
133
|
+
throw new Error(`Claude 执行失败:${sanitizeTerminalErrorDetail(JSON.stringify(msg.errors ?? msg.result ?? msg.subtype ?? "未提供错误详情"))}`);
|
|
134
|
+
}
|
|
130
135
|
// SDK result/success 是 Claude 对本轮完整结束的权威确认。文本已经由之前的
|
|
131
136
|
// assistant 消息累计,因此这里只发送终态信号,避免重复追加 result 文本。
|
|
132
137
|
if (msg.type === "result" && msg.subtype === "success") {
|
|
@@ -399,6 +404,7 @@ class ClaudeAdapter {
|
|
|
399
404
|
return;
|
|
400
405
|
let aborted = false;
|
|
401
406
|
let completed = false;
|
|
407
|
+
const completion = createTurnCompletion("Claude");
|
|
402
408
|
const rawLogConfig = config.rawStreamLogs.claude;
|
|
403
409
|
let rawLog = null;
|
|
404
410
|
const sdk = await loadClaudeSdkModule();
|
|
@@ -449,10 +455,24 @@ class ClaudeAdapter {
|
|
|
449
455
|
}
|
|
450
456
|
}
|
|
451
457
|
const normalized = normalizeSdkMessage(msg);
|
|
458
|
+
completion.observe(normalized);
|
|
459
|
+
if (msg.type === "result") {
|
|
460
|
+
// Some SDK modes only put the final reply in result.result.
|
|
461
|
+
if (msg.result?.trim()) {
|
|
462
|
+
const final = { type: "assistant", blocks: [{ type: "text_final", text: msg.result }] };
|
|
463
|
+
completion.observe(final);
|
|
464
|
+
yield final;
|
|
465
|
+
}
|
|
466
|
+
completion.complete();
|
|
467
|
+
completed = true;
|
|
468
|
+
}
|
|
452
469
|
if (normalized)
|
|
453
470
|
yield normalized;
|
|
471
|
+
if (completed)
|
|
472
|
+
break;
|
|
454
473
|
}
|
|
455
|
-
|
|
474
|
+
if (!aborted && !abortController.signal.aborted)
|
|
475
|
+
completion.assertComplete();
|
|
456
476
|
}
|
|
457
477
|
finally {
|
|
458
478
|
removeAbortListener?.();
|
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
// - getSessionInfo: 从持久化映射读取 cwd / threadId
|
|
8
8
|
// =============================================================================
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
|
+
import { createTurnCompletion } from "./turn-completion.js";
|
|
11
|
+
import { cliProcessOptions, ensureCliSessionReleased, ownCliProcess } from "./managed-cli-process.js";
|
|
10
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
13
|
import { join } from "node:path";
|
|
12
14
|
import { randomUUID } from "node:crypto";
|
|
13
15
|
import { parseUserCommand } from "./adapter-interface.js";
|
|
14
16
|
import { defaultCodexSessionMetaStore, } from "./codex-session-meta-store.js";
|
|
15
|
-
import { killProcessTree } from "./proc-tree-kill.js";
|
|
16
17
|
import { config, PROJECT_ROOT, RAW_STREAM_LOGS_DIR } from "../config.js";
|
|
17
18
|
import { createRawStreamLog, } from "./raw-stream-log.js";
|
|
18
19
|
import { readJsonLinesWithBadJsonIdleWatchdog } from "./jsonl-stream.js";
|
|
@@ -71,8 +72,8 @@ function resolveCodexEffort() {
|
|
|
71
72
|
// normalizeCodexMessage — Codex 事件 → UnifiedStreamMessage | null
|
|
72
73
|
// ---------------------------------------------------------------------------
|
|
73
74
|
export function normalizeCodexMessage(msg) {
|
|
74
|
-
if (msg.type === "error"
|
|
75
|
-
throw new Error(`Codex turn failed: ${msg.message
|
|
75
|
+
if (msg.type === "error") {
|
|
76
|
+
throw new Error(`Codex turn failed: ${msg.message?.trim() || "未提供错误详情"}`);
|
|
76
77
|
}
|
|
77
78
|
if (msg.type === "turn.failed") {
|
|
78
79
|
const detail = typeof msg.error === "string" ? msg.error : msg.error?.message;
|
|
@@ -158,12 +159,21 @@ function spawnCodex(args, cwd, stdinText, modelOverride, effortOverride, fastMod
|
|
|
158
159
|
stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
|
|
159
160
|
windowsHide: true,
|
|
160
161
|
shell: true,
|
|
162
|
+
...cliProcessOptions(),
|
|
161
163
|
});
|
|
162
164
|
let stderr = "";
|
|
165
|
+
let exitCode = null;
|
|
166
|
+
let signal = null;
|
|
167
|
+
let settleClose = () => { };
|
|
168
|
+
const closed = new Promise(resolve => { settleClose = resolve; });
|
|
169
|
+
proc.once("error", (error) => { stderr += `\n${error.message}`; settleClose(); });
|
|
163
170
|
proc.stderr.on("data", (chunk) => {
|
|
164
171
|
stderr += chunk.toString();
|
|
165
172
|
});
|
|
166
|
-
proc.on("close", (code) => {
|
|
173
|
+
proc.on("close", (code, exitSignal) => {
|
|
174
|
+
exitCode = code;
|
|
175
|
+
signal = exitSignal;
|
|
176
|
+
settleClose();
|
|
167
177
|
if (code !== 0 && stderr.trim()) {
|
|
168
178
|
console.error(`[Codex stderr] exit=${code}: ${stderr.trim().slice(0, 2000)}`);
|
|
169
179
|
}
|
|
@@ -172,7 +182,17 @@ function spawnCodex(args, cwd, stdinText, modelOverride, effortOverride, fastMod
|
|
|
172
182
|
proc.stdin.write(stdinText);
|
|
173
183
|
proc.stdin.end();
|
|
174
184
|
}
|
|
175
|
-
return proc
|
|
185
|
+
return { proc, async failureDetail() {
|
|
186
|
+
let timer;
|
|
187
|
+
try {
|
|
188
|
+
await Promise.race([closed, new Promise(resolve => { timer = setTimeout(resolve, 2_000); })]);
|
|
189
|
+
return `exit=${exitCode ?? "unknown"}; signal=${signal ?? "none"}; ${stderr.trim() || "无 stderr 详情"}`;
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
if (timer)
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
}
|
|
195
|
+
} };
|
|
176
196
|
}
|
|
177
197
|
async function* readJsonLines(proc, signal, rawLog) {
|
|
178
198
|
yield* readJsonLinesWithBadJsonIdleWatchdog({
|
|
@@ -207,6 +227,9 @@ class CodexAdapter {
|
|
|
207
227
|
return { sessionId };
|
|
208
228
|
}
|
|
209
229
|
async *prompt(sessionId, userText, cwd, signal, options) {
|
|
230
|
+
if (signal?.aborted)
|
|
231
|
+
return;
|
|
232
|
+
await ensureCliSessionReleased(sessionId);
|
|
210
233
|
let meta = await this.metaStore.get(sessionId);
|
|
211
234
|
const threadId = meta?.threadId;
|
|
212
235
|
const isFirstPrompt = !threadId;
|
|
@@ -219,7 +242,9 @@ class CodexAdapter {
|
|
|
219
242
|
const args = isFirstPrompt
|
|
220
243
|
? [...baseArgs, "-C", cwd, "-"]
|
|
221
244
|
: [...baseArgs, "resume", threadId, "-"];
|
|
222
|
-
const
|
|
245
|
+
const handle = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride, this.effortOverride, this.fastModeOverride);
|
|
246
|
+
const proc = handle.proc;
|
|
247
|
+
const ownership = ownCliProcess(sessionId, proc.pid);
|
|
223
248
|
if (proc.pid !== undefined)
|
|
224
249
|
options?.onProcessStart?.({ pid: proc.pid });
|
|
225
250
|
const rawLogConfig = config.rawStreamLogs.codex;
|
|
@@ -242,15 +267,14 @@ class CodexAdapter {
|
|
|
242
267
|
// 真正干活的是壳的孙子 codex.exe。普通 proc.kill() 在 Windows 上只杀第一层,
|
|
243
268
|
// 会留下幽灵 node + codex.exe 继续烧 token、stream-state 永远停在 running。
|
|
244
269
|
// 因此 abort 与 finally 都必须用 killProcessTree 整棵进程树一起收尸。
|
|
245
|
-
const onAbort = () => { void
|
|
270
|
+
const onAbort = () => { void ownership.stop().catch(() => { }); };
|
|
246
271
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
247
272
|
let completed = false;
|
|
273
|
+
const completion = createTurnCompletion("Codex");
|
|
248
274
|
try {
|
|
249
275
|
for await (const raw of readJsonLines(proc, signal, rawLog)) {
|
|
250
276
|
if (signal?.aborted)
|
|
251
277
|
break;
|
|
252
|
-
if (raw.type === "turn.completed")
|
|
253
|
-
completed = true;
|
|
254
278
|
if (isFirstPrompt &&
|
|
255
279
|
raw.type === "thread.started" &&
|
|
256
280
|
raw.thread_id) {
|
|
@@ -259,16 +283,31 @@ class CodexAdapter {
|
|
|
259
283
|
.catch(() => { });
|
|
260
284
|
}
|
|
261
285
|
const normalized = normalizeCodexMessage(raw);
|
|
286
|
+
completion.observe(normalized);
|
|
287
|
+
if (raw.type === "turn.completed") {
|
|
288
|
+
completion.complete();
|
|
289
|
+
completed = true;
|
|
290
|
+
}
|
|
262
291
|
if (normalized)
|
|
263
292
|
yield normalized;
|
|
293
|
+
if (completed)
|
|
294
|
+
break;
|
|
264
295
|
}
|
|
296
|
+
if (!signal?.aborted && !completed)
|
|
297
|
+
completion.assertComplete(await handle.failureDetail());
|
|
265
298
|
}
|
|
266
299
|
finally {
|
|
267
300
|
signal?.removeEventListener("abort", onAbort);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
301
|
+
let released = false;
|
|
302
|
+
try {
|
|
303
|
+
await ownership.stop();
|
|
304
|
+
released = true;
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
await rawLog?.close({ keep: !released || rawLogConfig.keepCompleted || signal?.aborted === true || !completed });
|
|
308
|
+
if (released && proc.pid !== undefined)
|
|
309
|
+
options?.onProcessExit?.({ pid: proc.pid });
|
|
310
|
+
}
|
|
272
311
|
}
|
|
273
312
|
}
|
|
274
313
|
async getSessionInfo(sessionId) {
|
|
@@ -8,6 +8,8 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { parseUserCommand } from "./adapter-interface.js";
|
|
11
|
+
import { createTurnCompletion } from "./turn-completion.js";
|
|
12
|
+
import { cliProcessOptions, ensureCliSessionReleased, ownCliProcess } from "./managed-cli-process.js";
|
|
11
13
|
import { config, CURSOR_AGENT_COMMAND, CURSOR_AGENT_ARGS, PROJECT_ROOT, RAW_STREAM_LOGS_DIR } from "../config.js";
|
|
12
14
|
import { defaultCursorSessionMetaStore, } from "./cursor-session-meta-store.js";
|
|
13
15
|
import { killProcessTree } from "./proc-tree-kill.js";
|
|
@@ -106,6 +108,15 @@ function mapToolCallKey(key) {
|
|
|
106
108
|
return KEY_MAP[key] ?? key;
|
|
107
109
|
}
|
|
108
110
|
export function normalizeCursorMessage(msg) {
|
|
111
|
+
if (msg.type === "error" || (msg.type === "result" && (msg.is_error === true || msg.subtype?.startsWith("error")))) {
|
|
112
|
+
throw new Error(`Cursor 执行失败:${formatCursorVisibleStderr(JSON.stringify(msg.errors ?? msg.error ?? msg.result ?? msg.subtype ?? "未提供错误详情"))}`);
|
|
113
|
+
}
|
|
114
|
+
if (msg.type === "connection" || msg.type === "retry") {
|
|
115
|
+
return { type: "system", blocks: [{ type: "agent_status",
|
|
116
|
+
status: msg.subtype === "reconnected" ? "responding" : "reconnecting",
|
|
117
|
+
...(Number.isInteger(msg.attempt) && msg.attempt > 0 ? { attempt: msg.attempt } : {}),
|
|
118
|
+
}] };
|
|
119
|
+
}
|
|
109
120
|
if (msg.type === "assistant" && msg.message?.content) {
|
|
110
121
|
// 按 cursor 官方 stream-json 规范区分三类 assistant 事件,避免 text 重复累加:
|
|
111
122
|
// ┌────────────────┬───────────────┬─────────────────┐
|
|
@@ -278,6 +289,7 @@ function spawnAgent(extraArgs, cwd, stdinText, modelOverride, mode, spawnImpl =
|
|
|
278
289
|
stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
|
|
279
290
|
windowsHide: true,
|
|
280
291
|
shell: true,
|
|
292
|
+
...cliProcessOptions(),
|
|
281
293
|
});
|
|
282
294
|
console.log(`[Cursor debug] spawn: cmd=${CURSOR_AGENT_COMMAND}, args=[${allArgs.join(", ")}], cwd=${cwd ?? "(none)"}, stdinLen=${stdinText?.length ?? 0}, pid=${proc.pid}`);
|
|
283
295
|
// 收集 stderr,子进程异常退出时输出到日志,方便排查静默失败
|
|
@@ -312,8 +324,17 @@ function spawnAgent(extraArgs, cwd, stdinText, modelOverride, mode, spawnImpl =
|
|
|
312
324
|
waitForClose: async () => {
|
|
313
325
|
if (closeInfo)
|
|
314
326
|
return { ...closeInfo, stderr };
|
|
315
|
-
|
|
316
|
-
|
|
327
|
+
let timer;
|
|
328
|
+
try {
|
|
329
|
+
const info = await Promise.race([closePromise, new Promise(resolve => {
|
|
330
|
+
timer = setTimeout(() => resolve({ code: null, signal: null, stderr }), 2_000);
|
|
331
|
+
})]);
|
|
332
|
+
return { ...info, stderr };
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
if (timer)
|
|
336
|
+
clearTimeout(timer);
|
|
337
|
+
}
|
|
317
338
|
},
|
|
318
339
|
};
|
|
319
340
|
}
|
|
@@ -392,15 +413,20 @@ class CursorAdapter {
|
|
|
392
413
|
}
|
|
393
414
|
finally {
|
|
394
415
|
signal?.removeEventListener("abort", onAbort);
|
|
395
|
-
await killProcessTree(proc.pid)
|
|
416
|
+
if (await killProcessTree(proc.pid) === false)
|
|
417
|
+
throw new Error(`Cursor 初始化进程未确认退出(PID ${proc.pid})`);
|
|
396
418
|
this.activeProcs.delete(proc);
|
|
397
419
|
}
|
|
398
420
|
}
|
|
399
421
|
async *prompt(sessionId, userText, cwd, signal, options) {
|
|
422
|
+
if (signal?.aborted)
|
|
423
|
+
return;
|
|
424
|
+
await ensureCliSessionReleased(sessionId);
|
|
400
425
|
console.log(`[Cursor debug] prompt start: sessionId=${sessionId}, cwd=${cwd}, userTextLen=${userText.length}`);
|
|
401
426
|
const cmd = parseUserCommand(userText);
|
|
402
427
|
const handle = spawnAgent(["--resume", sessionId], cwd, buildCursorPromptText(userText), this.modelOverride, cmd.mode ?? undefined, this.spawnImpl);
|
|
403
428
|
const proc = handle.proc;
|
|
429
|
+
const ownership = ownCliProcess(sessionId, proc.pid);
|
|
404
430
|
this.activeProcs.add(proc);
|
|
405
431
|
if (proc.pid !== undefined)
|
|
406
432
|
options?.onProcessStart?.({ pid: proc.pid });
|
|
@@ -422,9 +448,10 @@ class CursorAdapter {
|
|
|
422
448
|
}
|
|
423
449
|
// 见 codex-adapter.ts 同位置注释:spawn 用了 shell:true,必须杀整棵树,
|
|
424
450
|
// 否则 abort 后真正在跑的孙进程 cursor-agent 还会继续输出 & 占用资源。
|
|
425
|
-
const onAbort = () => { void
|
|
451
|
+
const onAbort = () => { void ownership.stop().catch(() => { }); };
|
|
426
452
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
427
453
|
let sawResult = false;
|
|
454
|
+
const completion = createTurnCompletion("Cursor");
|
|
428
455
|
const stats = createCursorStreamStats();
|
|
429
456
|
try {
|
|
430
457
|
for await (const raw of readJsonLines(proc, signal, sessionId, rawLog, stats, this.badJsonIdleTimeoutMs)) {
|
|
@@ -439,12 +466,17 @@ class CursorAdapter {
|
|
|
439
466
|
.catch(() => { });
|
|
440
467
|
}
|
|
441
468
|
const normalized = normalizeCursorMessage(raw);
|
|
469
|
+
completion.observe(normalized);
|
|
470
|
+
if (raw.type === "result")
|
|
471
|
+
completion.complete();
|
|
442
472
|
if (normalized)
|
|
443
473
|
yield normalized;
|
|
444
474
|
// result 是流末事件,收到后立即结束进程,防止 CLI 僵死导致 readline 挂起。
|
|
445
475
|
if (raw.type === "result") {
|
|
476
|
+
if (!normalized?.isFinalResponse)
|
|
477
|
+
yield { type: "assistant", blocks: [], isFinalResponse: true };
|
|
446
478
|
sawResult = true;
|
|
447
|
-
void
|
|
479
|
+
void ownership.stop().catch(() => { });
|
|
448
480
|
break;
|
|
449
481
|
}
|
|
450
482
|
}
|
|
@@ -462,15 +494,22 @@ class CursorAdapter {
|
|
|
462
494
|
};
|
|
463
495
|
throw createCursorAgentFailureError(closeInfo);
|
|
464
496
|
}
|
|
497
|
+
completion.assertComplete(`exit=${closeInfo.code ?? "unknown"}; signal=${closeInfo.signal ?? "none"}; ${formatCursorVisibleStderr(closeInfo.stderr) || "无 stderr 详情"}`);
|
|
465
498
|
}
|
|
466
499
|
}
|
|
467
500
|
finally {
|
|
468
501
|
signal?.removeEventListener("abort", onAbort);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
502
|
+
let released = false;
|
|
503
|
+
try {
|
|
504
|
+
await ownership.stop();
|
|
505
|
+
released = true;
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
await rawLog?.close({ keep: !released || rawLogConfig.keepCompleted || signal?.aborted === true || !sawResult });
|
|
509
|
+
this.activeProcs.delete(proc);
|
|
510
|
+
if (released && proc.pid !== undefined)
|
|
511
|
+
options?.onProcessExit?.({ pid: proc.pid });
|
|
512
|
+
}
|
|
474
513
|
console.log(`[Cursor debug] prompt end: sessionId=${sessionId}, signalAborted=${signal?.aborted ?? false}`);
|
|
475
514
|
}
|
|
476
515
|
}
|
|
@@ -125,22 +125,18 @@ export function createDshAdapter(options = {}) {
|
|
|
125
125
|
queue.push(message);
|
|
126
126
|
},
|
|
127
127
|
});
|
|
128
|
-
if (
|
|
128
|
+
if (turnError) {
|
|
129
|
+
if (result.finalResponse?.trim())
|
|
130
|
+
queue.push({ type: "assistant", blocks: [{ type: "text_final", text: result.finalResponse }] });
|
|
131
|
+
queue.fail(turnError);
|
|
132
|
+
}
|
|
133
|
+
else if (result.finalResponse?.trim()) {
|
|
129
134
|
rawLog?.writeLine(JSON.stringify({ type: "run.result", result }));
|
|
130
135
|
queue.push({ type: "assistant", blocks: [{ type: "text_final", text: result.finalResponse }], isFinalResponse: true });
|
|
131
136
|
completed = true;
|
|
132
137
|
}
|
|
133
|
-
else if (turnError) {
|
|
134
|
-
queue.fail(turnError);
|
|
135
|
-
}
|
|
136
138
|
else {
|
|
137
|
-
|
|
138
|
-
queue.push({
|
|
139
|
-
type: "assistant",
|
|
140
|
-
blocks: [{ type: "text_final", text: "DeepSeek Harness 本轮未产生任何回复(引擎未报告错误)。" }],
|
|
141
|
-
isFinalResponse: true,
|
|
142
|
-
});
|
|
143
|
-
completed = true;
|
|
139
|
+
queue.fail(new Error("DeepSeek Harness 本轮未产生有效回复(引擎未报告错误)。"));
|
|
144
140
|
}
|
|
145
141
|
if (completed) {
|
|
146
142
|
knownSessions.set(sessionId, { sessionId, cwd, lastModified: Date.now(), model: options.model ?? "deepseek-v4-flash" });
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { killProcessTree } from "./proc-tree-kill.js";
|
|
2
|
+
export function cliProcessOptions(platform = process.platform) {
|
|
3
|
+
return { detached: platform !== "win32" };
|
|
4
|
+
}
|
|
5
|
+
const owners = new Map();
|
|
6
|
+
export class ProcessCleanupError extends Error {
|
|
7
|
+
code = "PROCESS_CLEANUP_FAILED";
|
|
8
|
+
}
|
|
9
|
+
/** A new adapter instance must not bypass a previous failed cleanup. */
|
|
10
|
+
export async function ensureCliSessionReleased(sessionId) {
|
|
11
|
+
const owner = owners.get(sessionId);
|
|
12
|
+
if (!owner)
|
|
13
|
+
return;
|
|
14
|
+
if (!owner.stopping)
|
|
15
|
+
throw new Error("该会话的 Agent 仍在执行,暂不能启动另一个进程");
|
|
16
|
+
await owner.stop();
|
|
17
|
+
}
|
|
18
|
+
export function ownCliProcess(sessionId, pid) {
|
|
19
|
+
let pending;
|
|
20
|
+
let finished = false;
|
|
21
|
+
const owner = {
|
|
22
|
+
stopping: false,
|
|
23
|
+
stop() {
|
|
24
|
+
if (finished)
|
|
25
|
+
return Promise.resolve();
|
|
26
|
+
if (pending)
|
|
27
|
+
return pending;
|
|
28
|
+
owner.stopping = true;
|
|
29
|
+
pending = killProcessTree(pid).then(ok => {
|
|
30
|
+
if (ok === false)
|
|
31
|
+
throw new ProcessCleanupError(`Agent 进程未确认退出(PID ${pid}),会话仍受保护;请重试停止或检查残留进程后再继续。`);
|
|
32
|
+
finished = true;
|
|
33
|
+
if (owners.get(sessionId) === owner)
|
|
34
|
+
owners.delete(sessionId);
|
|
35
|
+
}).finally(() => { pending = undefined; });
|
|
36
|
+
return pending;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
// Test/non-process adapters can have no PID; they own no OS resource.
|
|
40
|
+
if (pid !== undefined)
|
|
41
|
+
owners.set(sessionId, owner);
|
|
42
|
+
return owner;
|
|
43
|
+
}
|
|
@@ -1,94 +1,95 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
// 收到任何信号、继续运行,导致用户 /stop 看似生效(adapter 标记 stopped)但
|
|
14
|
-
// 实际 codex 仍在后台跑、stream-state 一直停在 "running"。
|
|
15
|
-
//
|
|
16
|
-
// 解决方案:abort 时不要走 proc.kill(),而是用本工具按 pid 杀掉整棵进程树。
|
|
17
|
-
// - Windows: `taskkill /pid <pid> /T /F`(/T = 递归子进程, /F = 强制)
|
|
18
|
-
// - 其它:`process.kill(-pgid, "SIGTERM")` + 兜底 SIGKILL(adapter spawn 时
|
|
19
|
-
// 需配合 detached:true 让子进程拥有独立 process group)
|
|
20
|
-
// =============================================================================
|
|
21
|
-
import { spawn } from "node:child_process";
|
|
22
|
-
/** 异步杀掉以 pid 为根的整棵进程树。
|
|
23
|
-
*
|
|
24
|
-
* 设计目标:永不抛错、永不阻塞调用者。
|
|
25
|
-
* - pid 不存在、参数缺失 → 静默返回
|
|
26
|
-
* - 子进程 spawn 失败 → console.warn 但不 reject
|
|
27
|
-
* - Windows 上 taskkill 异步执行,不阻塞 event loop
|
|
28
|
-
*
|
|
29
|
-
* 调用方约定:返回的 Promise 在 kill 命令发出后立即 resolve。
|
|
30
|
-
* 真正的进程退出由 OS 异步完成,调用方如果需要确认"已死透",应自己再轮询
|
|
31
|
-
* `process.kill(pid, 0)`。
|
|
32
|
-
*/
|
|
33
|
-
export async function killProcessTree(pid) {
|
|
34
|
-
if (pid == null || !Number.isFinite(pid) || pid <= 0)
|
|
35
|
-
return;
|
|
36
|
-
if (process.platform === "win32") {
|
|
37
|
-
await killWindowsTree(pid);
|
|
38
|
-
return;
|
|
1
|
+
// Process groups are created by cliProcessOptions on POSIX. Never target a
|
|
2
|
+
// shared parent group. A false result means callers must retain ownership.
|
|
3
|
+
import { spawn, execFile } from "node:child_process";
|
|
4
|
+
const pending = new Map();
|
|
5
|
+
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
6
|
+
function alive(pid) {
|
|
7
|
+
try {
|
|
8
|
+
process.kill(pid, 0);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
return error.code !== "ESRCH";
|
|
39
13
|
}
|
|
40
|
-
await killPosixTree(pid);
|
|
41
14
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
15
|
+
/** Confirm the group, not only its leader: descendants may outlive the shell. */
|
|
16
|
+
export async function terminatePosixGroup(pid, deps) {
|
|
17
|
+
const send = (target, signal) => {
|
|
18
|
+
try {
|
|
19
|
+
deps.signal(target, signal);
|
|
20
|
+
}
|
|
21
|
+
catch { /* confirmation below decides success */ }
|
|
22
|
+
};
|
|
23
|
+
send(-pid, "SIGTERM");
|
|
24
|
+
send(pid, "SIGTERM");
|
|
25
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
26
|
+
if (!await deps.hasLiveMembers(pid))
|
|
27
|
+
return true;
|
|
28
|
+
await deps.sleep(100);
|
|
29
|
+
}
|
|
30
|
+
send(-pid, "SIGKILL");
|
|
31
|
+
send(pid, "SIGKILL");
|
|
32
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
33
|
+
if (!await deps.hasLiveMembers(pid))
|
|
34
|
+
return true;
|
|
35
|
+
await deps.sleep(100);
|
|
36
|
+
}
|
|
37
|
+
return !await deps.hasLiveMembers(pid);
|
|
38
|
+
}
|
|
39
|
+
function posixMembersAlive(pid) {
|
|
40
|
+
return new Promise(resolve => {
|
|
41
|
+
execFile("ps", ["-eo", "pid=,pgid=,stat="], { timeout: 2_000, maxBuffer: 4 * 1024 * 1024 }, (error, output) => {
|
|
42
|
+
if (error) {
|
|
43
|
+
resolve(alive(-pid) || alive(pid));
|
|
50
44
|
return;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
}
|
|
46
|
+
// Zombies have already released files/locks; waiting for init to reap
|
|
47
|
+
// them would otherwise block containers with a non-reaping PID 1.
|
|
48
|
+
resolve(output.split("\n").some(line => {
|
|
49
|
+
const [id, group, state] = line.trim().split(/\s+/);
|
|
50
|
+
return (Number(id) === pid || Number(group) === pid) && !!state && !/^[ZX]/.test(state);
|
|
51
|
+
}));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function killWindowsTree(pid) {
|
|
56
|
+
if (!alive(pid))
|
|
57
|
+
return Promise.resolve(true);
|
|
58
|
+
return new Promise(resolve => {
|
|
59
|
+
let settled = false;
|
|
60
|
+
let timer;
|
|
61
|
+
const done = (ok) => { if (!settled) {
|
|
62
|
+
settled = true;
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
resolve(ok);
|
|
65
|
+
} };
|
|
66
|
+
timer = setTimeout(() => done(false), 5_000);
|
|
54
67
|
try {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// taskkill 本身很快(<200ms),不需要 detached
|
|
59
|
-
});
|
|
60
|
-
proc.once("error", (err) => {
|
|
61
|
-
console.warn(`[killProcessTree] taskkill spawn error for pid=${pid}: ${err.message}`);
|
|
62
|
-
done();
|
|
63
|
-
});
|
|
64
|
-
proc.once("close", () => { done(); });
|
|
65
|
-
// 兜底超时:3 秒后强制 resolve,避免极端情况下 hang 住调用方
|
|
66
|
-
setTimeout(done, 3000).unref();
|
|
68
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
69
|
+
killer.once("error", () => done(false));
|
|
70
|
+
killer.once("close", (code) => done(code === 0 && !alive(pid)));
|
|
67
71
|
}
|
|
68
|
-
catch
|
|
69
|
-
|
|
70
|
-
done();
|
|
72
|
+
catch {
|
|
73
|
+
done(false);
|
|
71
74
|
}
|
|
72
75
|
});
|
|
73
76
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// 进程已不存在或权限不足,忽略
|
|
93
|
-
}
|
|
77
|
+
/** Coalesce abort/watchdog/finally calls; success requires observed termination. */
|
|
78
|
+
export function killProcessTree(pid) {
|
|
79
|
+
if (pid == null)
|
|
80
|
+
return Promise.resolve(true);
|
|
81
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid || pid === process.ppid)
|
|
82
|
+
return Promise.resolve(false);
|
|
83
|
+
const existing = pending.get(pid);
|
|
84
|
+
if (existing)
|
|
85
|
+
return existing;
|
|
86
|
+
const operation = (process.platform === "win32" ? killWindowsTree(pid) : terminatePosixGroup(pid, {
|
|
87
|
+
signal: (target, signal) => { process.kill(target, signal); }, hasLiveMembers: posixMembersAlive, sleep: delay,
|
|
88
|
+
})).catch(() => false).then(ok => {
|
|
89
|
+
if (!ok)
|
|
90
|
+
console.error(`[killProcessTree] cleanup not confirmed for PID ${pid}`);
|
|
91
|
+
return ok;
|
|
92
|
+
}).finally(() => pending.delete(pid));
|
|
93
|
+
pending.set(pid, operation);
|
|
94
|
+
return operation;
|
|
94
95
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { sanitizeTerminalErrorDetail } from "../terminal-error.js";
|
|
2
|
+
/** Protocol output (init, echo, heartbeat, tools) is not a successful reply. */
|
|
3
|
+
export function createTurnCompletion(tool) {
|
|
4
|
+
let hasReply = false;
|
|
5
|
+
let completed = false;
|
|
6
|
+
return {
|
|
7
|
+
observe(message) {
|
|
8
|
+
for (const block of message?.blocks ?? []) {
|
|
9
|
+
if (block.type === "text_reset")
|
|
10
|
+
hasReply = false;
|
|
11
|
+
if (message?.type === "assistant" && (block.type === "text" || block.type === "text_final") && block.text.trim())
|
|
12
|
+
hasReply = true;
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
complete() {
|
|
16
|
+
if (!hasReply)
|
|
17
|
+
throw new Error(`${tool} 本轮未产生有效回复(收到结束事件,但回复为空)。`);
|
|
18
|
+
completed = true;
|
|
19
|
+
},
|
|
20
|
+
assertComplete(detail = "") {
|
|
21
|
+
if (!completed)
|
|
22
|
+
throw new Error(`${tool} 未正常完成:输出流结束但未收到成功完成事件${detail ? `;${sanitizeTerminalErrorDetail(detail)}` : ""}。`);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -7,6 +7,7 @@ export function createAgentActivityTracker(now = Date.now()) {
|
|
|
7
7
|
}
|
|
8
8
|
function sameVisibleActivity(left, right) {
|
|
9
9
|
return left.kind === right.kind
|
|
10
|
+
&& left.attempt === right.attempt
|
|
10
11
|
&& left.toolName === right.toolName
|
|
11
12
|
&& left.toolCount === right.toolCount;
|
|
12
13
|
}
|
|
@@ -73,8 +74,9 @@ export function updateAgentActivity(tracker, block, now = Date.now()) {
|
|
|
73
74
|
return setActivity(tracker, { kind: "responding", startedAt: now });
|
|
74
75
|
case "agent_status":
|
|
75
76
|
return setActivity(tracker, {
|
|
76
|
-
kind: block.status
|
|
77
|
+
kind: block.status,
|
|
77
78
|
startedAt: now,
|
|
79
|
+
...(block.attempt ? { attempt: block.attempt } : {}),
|
|
78
80
|
});
|
|
79
81
|
case "thinking":
|
|
80
82
|
case "redacted_thinking":
|
|
@@ -126,6 +128,9 @@ export function formatAgentActivityTitle(activity, now = Date.now()) {
|
|
|
126
128
|
case "responding":
|
|
127
129
|
label = "正在生成回复";
|
|
128
130
|
break;
|
|
131
|
+
case "reconnecting":
|
|
132
|
+
label = `正在重新连接模型服务${activity.attempt ? `(第 ${activity.attempt} 次)` : ""}`;
|
|
133
|
+
break;
|
|
129
134
|
case "searching":
|
|
130
135
|
label = "正在处理搜索结果";
|
|
131
136
|
break;
|
package/dist/src/session.js
CHANGED
|
@@ -1199,6 +1199,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
|
|
|
1199
1199
|
const FILE_WRITE_INTERVAL_MS = 2000;
|
|
1200
1200
|
const toolCallMap = new Map();
|
|
1201
1201
|
let streamErrored = false;
|
|
1202
|
+
let cleanupFailed = false;
|
|
1202
1203
|
let streamTerminalError;
|
|
1203
1204
|
let runOutcome = "error";
|
|
1204
1205
|
const responseStallDetectionEnabled = adapter.responseStallDetectionEnabled !== false;
|
|
@@ -1369,6 +1370,9 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
|
|
|
1369
1370
|
}
|
|
1370
1371
|
catch (streamErr) {
|
|
1371
1372
|
streamErrored = true;
|
|
1373
|
+
cleanupFailed = streamErr?.code === "PROCESS_CLEANUP_FAILED";
|
|
1374
|
+
if (cleanupFailed)
|
|
1375
|
+
cancelAutoRecoveryReservation(sessionId);
|
|
1372
1376
|
streamTerminalError = classifyTerminalError(streamErr);
|
|
1373
1377
|
console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${streamErr.message}`);
|
|
1374
1378
|
}
|
|
@@ -1407,7 +1411,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
|
|
|
1407
1411
|
// 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
|
|
1408
1412
|
// writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
|
|
1409
1413
|
// 运行中并更新旧卡片,而不是新建卡片。
|
|
1410
|
-
const finalStatus = completedAtTimeoutBoundary
|
|
1414
|
+
const finalStatus = cleanupFailed ? "error" : completedAtTimeoutBoundary
|
|
1411
1415
|
? "done"
|
|
1412
1416
|
: wasAutoEnded
|
|
1413
1417
|
? "auto_ended"
|
|
@@ -1468,7 +1472,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
|
|
|
1468
1472
|
});
|
|
1469
1473
|
// display loop 下一轮会读到最终状态并发送消息
|
|
1470
1474
|
let autoRecoveryTarget;
|
|
1471
|
-
if (wasStopped) {
|
|
1475
|
+
if (wasStopped && !cleanupFailed) {
|
|
1472
1476
|
for (const cid of finalizationChatIds) {
|
|
1473
1477
|
const finfo = sessionInfoMap.get(cid);
|
|
1474
1478
|
await recordSessionRegistry({
|
|
@@ -1490,7 +1494,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
|
|
|
1490
1494
|
if (tid)
|
|
1491
1495
|
logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
|
|
1492
1496
|
}
|
|
1493
|
-
else if (wasAutoEnded) {
|
|
1497
|
+
else if (wasAutoEnded && !cleanupFailed) {
|
|
1494
1498
|
for (const cid of finalizationChatIds) {
|
|
1495
1499
|
const finfo = sessionInfoMap.get(cid);
|
|
1496
1500
|
await recordSessionRegistry({
|
|
@@ -1874,7 +1878,9 @@ export function startUnifiedDisplayLoop() {
|
|
|
1874
1878
|
displayCards.delete(chatId);
|
|
1875
1879
|
continue;
|
|
1876
1880
|
}
|
|
1877
|
-
const activityHeaderTitle =
|
|
1881
|
+
const activityHeaderTitle = activePrompts.get(sessionId)?.stopped
|
|
1882
|
+
? "正在停止 · 等待 Agent 退出"
|
|
1883
|
+
: formatAgentActivityTitle(state.activity, Date.now());
|
|
1878
1884
|
// 卡片轮转
|
|
1879
1885
|
if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
|
|
1880
1886
|
display.cardBusy = true;
|
|
@@ -2027,11 +2033,8 @@ export function stopUnifiedDisplayLoop() {
|
|
|
2027
2033
|
// 收尸;之前用 proc.kill() 在 Windows + shell:true 下只能杀第一层 cmd.exe,
|
|
2028
2034
|
// 会留下"幽灵 CLI 子进程"继续跑、stream-state 永远停在 running。
|
|
2029
2035
|
//
|
|
2030
|
-
// 2)
|
|
2031
|
-
//
|
|
2032
|
-
// 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
|
|
2033
|
-
// 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
|
|
2034
|
-
// finally 后续再写一次也不冲突——status 最终值仍然是 stopped。
|
|
2036
|
+
// 2) 保留 running 与会话占用,界面先显示正在停止;只有 adapter finally 确认
|
|
2037
|
+
// 进程退出后才由 runAgentSession 写 stopped,清理失败则显示错误。
|
|
2035
2038
|
export function stopSession(sessionId) {
|
|
2036
2039
|
// /stop 拥有高于内部自动恢复的优先级。旧轮已经完成、恢复轮尚在 200ms
|
|
2037
2040
|
// 预约窗口时 activePrompts 为空,因此必须单独取消 reservation。
|
|
@@ -2064,26 +2067,8 @@ export function stopSession(sessionId) {
|
|
|
2064
2067
|
}
|
|
2065
2068
|
prompt.controller.abort();
|
|
2066
2069
|
console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
|
|
2067
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
void (async () => {
|
|
2070
|
-
try {
|
|
2071
|
-
const current = await readStreamState(sessionId);
|
|
2072
|
-
if (!current)
|
|
2073
|
-
return;
|
|
2074
|
-
// 已经是终态就别再覆盖,避免把 done/error 误改成 stopped
|
|
2075
|
-
if (current.status !== "running")
|
|
2076
|
-
return;
|
|
2077
|
-
await writeStreamState({
|
|
2078
|
-
...current,
|
|
2079
|
-
status: "stopped",
|
|
2080
|
-
updatedAt: Date.now(),
|
|
2081
|
-
});
|
|
2082
|
-
}
|
|
2083
|
-
catch (err) {
|
|
2084
|
-
console.warn(`[${ts()}] [STOP] writeStreamState(stopped) failed for ${sessionId}: ${err.message}`);
|
|
2085
|
-
}
|
|
2086
|
-
})();
|
|
2070
|
+
// Keep durable state running until the adapter confirms process cleanup.
|
|
2071
|
+
// The display loop renders the in-memory stop request as "正在停止".
|
|
2087
2072
|
return true;
|
|
2088
2073
|
}
|
|
2089
2074
|
// ---------------------------------------------------------------------------
|
|
@@ -2148,7 +2133,7 @@ export async function getSessionStatus(chatId) {
|
|
|
2148
2133
|
if (!info)
|
|
2149
2134
|
return null;
|
|
2150
2135
|
const activePrompt = activePrompts.get(info.sessionId);
|
|
2151
|
-
const isActive = !!activePrompt
|
|
2136
|
+
const isActive = !!activePrompt;
|
|
2152
2137
|
const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
|
|
2153
2138
|
const registry = await loadSessionRegistry();
|
|
2154
2139
|
const chatName = registry[chatId]?.chatName ?? "";
|
|
@@ -2217,9 +2202,7 @@ export async function getAllSessionsStatus(options = {}) {
|
|
|
2217
2202
|
displayTitle: info.displayTitle || "",
|
|
2218
2203
|
pinned: info.pinned ?? false,
|
|
2219
2204
|
...(info.archivedAt ? { archivedAt: info.archivedAt } : {}),
|
|
2220
|
-
active:
|
|
2221
|
-
!activePrompts.get(info.sessionId)?.stopped &&
|
|
2222
|
-
!activePrompts.get(info.sessionId)?.abnormalExit,
|
|
2205
|
+
active: activePrompts.has(info.sessionId),
|
|
2223
2206
|
turnCount: info.turnCount,
|
|
2224
2207
|
startTime: info.startTime,
|
|
2225
2208
|
model,
|
|
@@ -35,9 +35,12 @@ function formatSeconds(milliseconds) {
|
|
|
35
35
|
export function classifyTerminalError(error, occurredAt = Date.now()) {
|
|
36
36
|
const raw = errorMessage(error);
|
|
37
37
|
const lower = raw.toLowerCase();
|
|
38
|
+
if (error?.code === "PROCESS_CLEANUP_FAILED") {
|
|
39
|
+
return { kind: "process", title: "Agent 停止未完成", message: sanitizeTerminalErrorDetail(raw), occurredAt };
|
|
40
|
+
}
|
|
38
41
|
const attempts = parsePositiveInt(raw, /\bafter\s+(\d+)\s+attempts?\b/i);
|
|
39
42
|
const timeoutMs = parsePositiveInt(raw, /\btimeout\s*:\s*(\d+)\s*ms\b/i);
|
|
40
|
-
if (/\b429\b|rate[ _-]?limit|too many requests/.test(lower)) {
|
|
43
|
+
if (/\b429\b|rate[ _-]?limit|too many requests|resource_exhausted/.test(lower)) {
|
|
41
44
|
return {
|
|
42
45
|
kind: "rate_limit",
|
|
43
46
|
title: "请求受到限流",
|
|
@@ -73,7 +76,7 @@ export function classifyTerminalError(error, occurredAt = Date.now()) {
|
|
|
73
76
|
};
|
|
74
77
|
}
|
|
75
78
|
const httpStatus = raw.match(/\b(?:HTTP\s*)?(5\d\d)\b/i)?.[1];
|
|
76
|
-
if (httpStatus || /service unavailable|bad gateway|gateway timeout|provider error/.test(lower)) {
|
|
79
|
+
if (httpStatus || /service unavailable|\[unavailable\]|bad gateway|gateway timeout|provider error/.test(lower)) {
|
|
77
80
|
return {
|
|
78
81
|
kind: "provider",
|
|
79
82
|
title: "模型服务暂时不可用",
|