chatccc 0.2.80 → 0.2.82
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/package.json +1 -1
- package/src/__tests__/proc-tree-kill.test.ts +108 -0
- package/src/__tests__/stop-session.test.ts +126 -0
- package/src/adapters/codex-adapter.ts +7 -3
- package/src/adapters/cursor-adapter.ts +7 -12
- package/src/adapters/proc-tree-kill.ts +97 -0
- package/src/session-chat-binding.ts +2 -0
- package/src/session.ts +72 -3
package/package.json
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// proc-tree-kill.test.ts — 进程树强杀工具单测护栏
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// 目的:覆盖 codex/cursor adapter 之前 proc.kill() 在 Windows + shell:true 下
|
|
5
|
+
// 只杀第一层 cmd.exe 壳、留下 node + 真二进制成"幽灵"的 bug。
|
|
6
|
+
// 测试构造一棵三层进程树(祖父 cmd/sh → 父 node → 子 sleep 进程),
|
|
7
|
+
// 用 killProcessTree 杀掉祖父 PID,断言整棵树都不存在了。
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from "vitest";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { writeFileSync, unlinkSync, existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { killProcessTree } from "../adapters/proc-tree-kill.ts";
|
|
16
|
+
|
|
17
|
+
function isAlive(pid: number): boolean {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0);
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function sleep(ms: number): Promise<void> {
|
|
27
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("killProcessTree", () => {
|
|
31
|
+
it("kills a 3-level process tree (shell wrapper + node child + node grandchild)", async () => {
|
|
32
|
+
// 用临时脚本模拟 codex CLI 的真实拓扑:
|
|
33
|
+
// layer-1: shell (cmd.exe / sh) — 因为 spawn 用了 shell:true
|
|
34
|
+
// layer-2: node 进程 — 模拟 `node codex.js` 入口
|
|
35
|
+
// layer-3: 又一个 node 进程 — 模拟真正干活的 codex.exe 子进程
|
|
36
|
+
const tmp = mkdtempSync(join(tmpdir(), "chatccc-proctree-"));
|
|
37
|
+
const childScript = join(tmp, "child.mjs");
|
|
38
|
+
const pidFile = join(tmp, "grand.pid");
|
|
39
|
+
const isWin = process.platform === "win32";
|
|
40
|
+
|
|
41
|
+
writeFileSync(
|
|
42
|
+
childScript,
|
|
43
|
+
[
|
|
44
|
+
`import { spawn } from "node:child_process";`,
|
|
45
|
+
`import { writeFileSync } from "node:fs";`,
|
|
46
|
+
// 启动孙进程:再开一个 node,跑一个 10 分钟的 setTimeout 占位
|
|
47
|
+
`const grand = spawn(process.execPath, ["-e", "setTimeout(()=>{},600000)"], { stdio: "ignore", detached: false });`,
|
|
48
|
+
`writeFileSync(${JSON.stringify(pidFile)}, String(grand.pid));`,
|
|
49
|
+
// 父进程自己也挂住别退出
|
|
50
|
+
`setTimeout(()=>{}, 600000);`,
|
|
51
|
+
].join("\n"),
|
|
52
|
+
"utf8",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// 启动祖父:复现 codex-adapter 用 shell:true 的方式
|
|
56
|
+
// 路径必须加引号,否则 cmd.exe 把空格当分隔符
|
|
57
|
+
const proc = spawn(`"${process.execPath}" "${childScript}"`, {
|
|
58
|
+
stdio: "ignore",
|
|
59
|
+
shell: true,
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
detached: !isWin,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(proc.pid).toBeGreaterThan(0);
|
|
65
|
+
|
|
66
|
+
// 等待孙进程 pid 被写出来(pidFile 出现表示中间 node 已成功 spawn 孙子)
|
|
67
|
+
let grandPid = 0;
|
|
68
|
+
for (let i = 0; i < 100; i++) {
|
|
69
|
+
if (existsSync(pidFile)) {
|
|
70
|
+
const s = readFileSync(pidFile, "utf8").trim();
|
|
71
|
+
if (s && /^\d+$/.test(s)) {
|
|
72
|
+
grandPid = parseInt(s, 10);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
await sleep(100);
|
|
77
|
+
}
|
|
78
|
+
expect(grandPid).toBeGreaterThan(0);
|
|
79
|
+
expect(isAlive(grandPid)).toBe(true);
|
|
80
|
+
|
|
81
|
+
// 杀祖父(在 shell:true 时 proc.pid 就是 cmd.exe / sh 那一层)。
|
|
82
|
+
// 关键断言:如果 killProcessTree 没有 /T 递归,grandPid 会留下来。
|
|
83
|
+
await killProcessTree(proc.pid!);
|
|
84
|
+
|
|
85
|
+
for (let i = 0; i < 50; i++) {
|
|
86
|
+
if (!isAlive(grandPid)) break;
|
|
87
|
+
await sleep(100);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
expect(isAlive(grandPid)).toBe(false);
|
|
91
|
+
expect(isAlive(proc.pid!)).toBe(false);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(childScript);
|
|
95
|
+
if (existsSync(pidFile)) unlinkSync(pidFile);
|
|
96
|
+
} catch {
|
|
97
|
+
// 忽略清理失败
|
|
98
|
+
}
|
|
99
|
+
}, 30000);
|
|
100
|
+
|
|
101
|
+
it("does not throw when pid does not exist", async () => {
|
|
102
|
+
await expect(killProcessTree(999999)).resolves.toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does not throw when pid is undefined", async () => {
|
|
106
|
+
await expect(killProcessTree(undefined)).resolves.toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// stop-session.test.ts — stopSession 单测护栏
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// 覆盖修复"幽灵 codex 会话"的两条关键行为:
|
|
5
|
+
// 1) controller.abort() 必须被触发(让 adapter finally 走 killProcessTree)
|
|
6
|
+
// 2) 立刻把 stream-state.status 改成 stopped,不依赖 runAgentSession 的 finally
|
|
7
|
+
// (那个 finally 要等 generator 自然结束,子进程没死透就一直停在 running)
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
11
|
+
import type { StreamState } from "../stream-state.ts";
|
|
12
|
+
|
|
13
|
+
// mock stream-state,使用模块内可观测的 Map 记录读写
|
|
14
|
+
const stateStore = new Map<string, StreamState>();
|
|
15
|
+
const writeCalls: StreamState[] = [];
|
|
16
|
+
|
|
17
|
+
vi.mock("../stream-state.ts", () => ({
|
|
18
|
+
readStreamState: async (sid: string): Promise<StreamState | null> => {
|
|
19
|
+
return stateStore.get(sid) ?? null;
|
|
20
|
+
},
|
|
21
|
+
writeStreamState: async (state: StreamState): Promise<void> => {
|
|
22
|
+
writeCalls.push(structuredClone(state));
|
|
23
|
+
stateStore.set(state.sessionId, state);
|
|
24
|
+
},
|
|
25
|
+
createEmptyStreamState: (sid: string, cwd: string, tool: string, turnCount: number) => ({
|
|
26
|
+
sessionId: sid,
|
|
27
|
+
status: "running" as const,
|
|
28
|
+
accumulatedContent: "",
|
|
29
|
+
finalReply: "",
|
|
30
|
+
chunkCount: 0,
|
|
31
|
+
turnCount,
|
|
32
|
+
contextTokens: 0,
|
|
33
|
+
updatedAt: Date.now(),
|
|
34
|
+
cwd,
|
|
35
|
+
tool,
|
|
36
|
+
}),
|
|
37
|
+
fixStaleStreamStates: async () => {},
|
|
38
|
+
STREAMS_DIR: "/tmp/streams-mock",
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
import { stopSession } from "../session.ts";
|
|
42
|
+
import { activePrompts } from "../session-chat-binding.ts";
|
|
43
|
+
|
|
44
|
+
function seedRunningSession(sid: string, accumulated = "partial output"): AbortController {
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
activePrompts.set(sid, { controller, stopped: false, startTime: Date.now() });
|
|
47
|
+
stateStore.set(sid, {
|
|
48
|
+
sessionId: sid,
|
|
49
|
+
status: "running",
|
|
50
|
+
accumulatedContent: accumulated,
|
|
51
|
+
finalReply: "",
|
|
52
|
+
chunkCount: 1,
|
|
53
|
+
turnCount: 1,
|
|
54
|
+
contextTokens: 0,
|
|
55
|
+
updatedAt: Date.now(),
|
|
56
|
+
cwd: "F:/repo",
|
|
57
|
+
tool: "codex",
|
|
58
|
+
});
|
|
59
|
+
return controller;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function flush(): Promise<void> {
|
|
63
|
+
// 让 stopSession 内 fire-and-forget 的 microtask 跑完
|
|
64
|
+
for (let i = 0; i < 5; i++) {
|
|
65
|
+
await Promise.resolve();
|
|
66
|
+
}
|
|
67
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
activePrompts.clear();
|
|
72
|
+
stateStore.clear();
|
|
73
|
+
writeCalls.length = 0;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("stopSession 行为护栏", () => {
|
|
77
|
+
it("没有活跃 session 时返回 false,不做任何事", async () => {
|
|
78
|
+
const ok = stopSession("nonexistent");
|
|
79
|
+
expect(ok).toBe(false);
|
|
80
|
+
await flush();
|
|
81
|
+
expect(writeCalls).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("abort controller + 立刻把 stream-state.status 写成 stopped", async () => {
|
|
85
|
+
const controller = seedRunningSession("sid-A", "hello world");
|
|
86
|
+
let aborted = false;
|
|
87
|
+
controller.signal.addEventListener("abort", () => { aborted = true; });
|
|
88
|
+
|
|
89
|
+
const ok = stopSession("sid-A");
|
|
90
|
+
expect(ok).toBe(true);
|
|
91
|
+
expect(aborted).toBe(true);
|
|
92
|
+
|
|
93
|
+
await flush();
|
|
94
|
+
|
|
95
|
+
// 关键护栏:必须有一次 writeStreamState 把 status 改成 stopped
|
|
96
|
+
expect(writeCalls.length).toBeGreaterThanOrEqual(1);
|
|
97
|
+
const lastWrite = writeCalls[writeCalls.length - 1];
|
|
98
|
+
expect(lastWrite.sessionId).toBe("sid-A");
|
|
99
|
+
expect(lastWrite.status).toBe("stopped");
|
|
100
|
+
// 累积内容不丢
|
|
101
|
+
expect(lastWrite.accumulatedContent).toBe("hello world");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("已经是终态(done/stopped/error)的 stream-state 不会被覆盖", async () => {
|
|
105
|
+
seedRunningSession("sid-B");
|
|
106
|
+
// 模拟 generator finally 已经先写了 done
|
|
107
|
+
stateStore.set("sid-B", {
|
|
108
|
+
...stateStore.get("sid-B")!,
|
|
109
|
+
status: "done",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const ok = stopSession("sid-B");
|
|
113
|
+
expect(ok).toBe(true);
|
|
114
|
+
await flush();
|
|
115
|
+
|
|
116
|
+
// 不应再有 stopped 覆盖 done 的写入
|
|
117
|
+
const stoppedWrites = writeCalls.filter((w) => w.status === "stopped");
|
|
118
|
+
expect(stoppedWrites).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("activePrompts 标记 stopped=true", async () => {
|
|
122
|
+
seedRunningSession("sid-C");
|
|
123
|
+
stopSession("sid-C");
|
|
124
|
+
expect(activePrompts.get("sid-C")?.stopped).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
defaultCodexSessionMetaStore,
|
|
23
23
|
type CodexSessionMetaStore,
|
|
24
24
|
} from "./codex-session-meta-store.ts";
|
|
25
|
+
import { killProcessTree } from "./proc-tree-kill.ts";
|
|
25
26
|
import { config } from "../config.ts";
|
|
26
27
|
|
|
27
28
|
// ---------------------------------------------------------------------------
|
|
@@ -242,14 +243,17 @@ class CodexAdapter implements ToolAdapter {
|
|
|
242
243
|
|
|
243
244
|
const proc = spawnCodex(args, cwd, userText);
|
|
244
245
|
|
|
245
|
-
|
|
246
|
+
// 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
|
|
247
|
+
// 真正干活的是壳的孙子 codex.exe。普通 proc.kill() 在 Windows 上只杀第一层,
|
|
248
|
+
// 会留下幽灵 node + codex.exe 继续烧 token、stream-state 永远停在 running。
|
|
249
|
+
// 因此 abort 与 finally 都必须用 killProcessTree 整棵进程树一起收尸。
|
|
250
|
+
const onAbort = () => { void killProcessTree(proc.pid); };
|
|
246
251
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
247
252
|
|
|
248
253
|
try {
|
|
249
254
|
for await (const raw of readJsonLines(proc, signal)) {
|
|
250
255
|
if (signal?.aborted) break;
|
|
251
256
|
|
|
252
|
-
// 首次 prompt 时从 thread.started 事件学习 threadId
|
|
253
257
|
if (
|
|
254
258
|
isFirstPrompt &&
|
|
255
259
|
raw.type === "thread.started" &&
|
|
@@ -265,7 +269,7 @@ class CodexAdapter implements ToolAdapter {
|
|
|
265
269
|
}
|
|
266
270
|
} finally {
|
|
267
271
|
signal?.removeEventListener("abort", onAbort);
|
|
268
|
-
proc.
|
|
272
|
+
await killProcessTree(proc.pid);
|
|
269
273
|
}
|
|
270
274
|
}
|
|
271
275
|
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
defaultCursorSessionMetaStore,
|
|
21
21
|
type CursorSessionMetaStore,
|
|
22
22
|
} from "./cursor-session-meta-store.ts";
|
|
23
|
+
import { killProcessTree } from "./proc-tree-kill.ts";
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// 类型:Cursor JSONL 消息行
|
|
@@ -319,20 +320,16 @@ class CursorAdapter implements ToolAdapter {
|
|
|
319
320
|
for await (const msg of readJsonLines(proc)) {
|
|
320
321
|
if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
|
|
321
322
|
const sessionId = msg.session_id;
|
|
322
|
-
// 持久化 sessionId → { cwd, model }:getSessionInfo 后续依赖此映射,
|
|
323
|
-
// 否则 Cursor 会话上的 /git、/status、/sessions 等命令将无法显示
|
|
324
|
-
// 真实工作目录与真实模型。优先用 init 事件自报字段(cursor-agent
|
|
325
|
-
// 内部权威),cwd 没有时退回调用方传入的 cwd 兜底。
|
|
326
323
|
await this.metaStore
|
|
327
324
|
.set(sessionId, { cwd: msg.cwd ?? cwd, model: msg.model })
|
|
328
325
|
.catch(() => {});
|
|
329
326
|
this.activeProcs.delete(proc);
|
|
330
|
-
proc.
|
|
327
|
+
await killProcessTree(proc.pid);
|
|
331
328
|
return { sessionId };
|
|
332
329
|
}
|
|
333
330
|
}
|
|
334
331
|
|
|
335
|
-
proc.
|
|
332
|
+
await killProcessTree(proc.pid);
|
|
336
333
|
this.activeProcs.delete(proc);
|
|
337
334
|
throw new Error("No session ID in Cursor init event");
|
|
338
335
|
}
|
|
@@ -346,16 +343,14 @@ class CursorAdapter implements ToolAdapter {
|
|
|
346
343
|
const proc = spawnAgent(["--resume", sessionId], cwd, userText);
|
|
347
344
|
this.activeProcs.add(proc);
|
|
348
345
|
|
|
349
|
-
|
|
346
|
+
// 见 codex-adapter.ts 同位置注释:spawn 用了 shell:true,必须杀整棵树,
|
|
347
|
+
// 否则 abort 后真正在跑的孙进程 cursor-agent 还会继续输出 & 占用资源。
|
|
348
|
+
const onAbort = () => { void killProcessTree(proc.pid); };
|
|
350
349
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
351
350
|
|
|
352
351
|
try {
|
|
353
352
|
for await (const raw of readJsonLines(proc, signal)) {
|
|
354
353
|
if (signal?.aborted) break;
|
|
355
|
-
// 自动学习:resume 流首条 init 事件若带 cwd / model,更新映射。
|
|
356
|
-
// 这是为升级前创建的旧会话或映射文件意外丢失的情况兜底——
|
|
357
|
-
// 用户向旧会话发一次消息后,/git、/status 等即可正常显示。
|
|
358
|
-
// fire-and-forget:写失败不影响流式输出。
|
|
359
354
|
if (
|
|
360
355
|
raw.type === "system" &&
|
|
361
356
|
raw.subtype === "init" &&
|
|
@@ -371,7 +366,7 @@ class CursorAdapter implements ToolAdapter {
|
|
|
371
366
|
}
|
|
372
367
|
} finally {
|
|
373
368
|
signal?.removeEventListener("abort", onAbort);
|
|
374
|
-
proc.
|
|
369
|
+
await killProcessTree(proc.pid);
|
|
375
370
|
this.activeProcs.delete(proc);
|
|
376
371
|
}
|
|
377
372
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// proc-tree-kill.ts — 跨平台进程树强杀工具
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// 背景:codex / cursor adapter 通过 `spawn(cmd, args, { shell: true })` 启动 CLI
|
|
5
|
+
// 时,Node 拿到的 proc.pid 是最外层 cmd.exe(Windows)或 /bin/sh(其它)的
|
|
6
|
+
// PID。真正干活的是它再 spawn 出来的:
|
|
7
|
+
//
|
|
8
|
+
// cmd.exe ← proc.kill() 只能杀到这一层
|
|
9
|
+
// └─ node codex.js ← Codex CLI 入口
|
|
10
|
+
// └─ codex.exe ← 实际 Rust 二进制(继续烧 token)
|
|
11
|
+
//
|
|
12
|
+
// 单纯 proc.kill() 在 Windows 上等价于 TerminateProcess 顶层壳,孙子进程不会
|
|
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
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
|
|
24
|
+
/** 异步杀掉以 pid 为根的整棵进程树。
|
|
25
|
+
*
|
|
26
|
+
* 设计目标:永不抛错、永不阻塞调用者。
|
|
27
|
+
* - pid 不存在、参数缺失 → 静默返回
|
|
28
|
+
* - 子进程 spawn 失败 → console.warn 但不 reject
|
|
29
|
+
* - Windows 上 taskkill 异步执行,不阻塞 event loop
|
|
30
|
+
*
|
|
31
|
+
* 调用方约定:返回的 Promise 在 kill 命令发出后立即 resolve。
|
|
32
|
+
* 真正的进程退出由 OS 异步完成,调用方如果需要确认"已死透",应自己再轮询
|
|
33
|
+
* `process.kill(pid, 0)`。
|
|
34
|
+
*/
|
|
35
|
+
export async function killProcessTree(pid: number | undefined): Promise<void> {
|
|
36
|
+
if (pid == null || !Number.isFinite(pid) || pid <= 0) return;
|
|
37
|
+
if (process.platform === "win32") {
|
|
38
|
+
await killWindowsTree(pid);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await killPosixTree(pid);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Windows: taskkill /T /F
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function killWindowsTree(pid: number): Promise<void> {
|
|
49
|
+
return new Promise<void>((resolve) => {
|
|
50
|
+
let resolved = false;
|
|
51
|
+
const done = () => {
|
|
52
|
+
if (resolved) return;
|
|
53
|
+
resolved = true;
|
|
54
|
+
resolve();
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
const proc = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
58
|
+
stdio: "ignore",
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
// taskkill 本身很快(<200ms),不需要 detached
|
|
61
|
+
});
|
|
62
|
+
proc.once("error", (err) => {
|
|
63
|
+
console.warn(`[killProcessTree] taskkill spawn error for pid=${pid}: ${(err as Error).message}`);
|
|
64
|
+
done();
|
|
65
|
+
});
|
|
66
|
+
proc.once("close", () => { done(); });
|
|
67
|
+
// 兜底超时:3 秒后强制 resolve,避免极端情况下 hang 住调用方
|
|
68
|
+
setTimeout(done, 3000).unref();
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.warn(`[killProcessTree] taskkill failed for pid=${pid}: ${(err as Error).message}`);
|
|
71
|
+
done();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// POSIX: 优先按 process group 杀,回退到按 pid 杀
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
async function killPosixTree(pid: number): Promise<void> {
|
|
81
|
+
// 第一次尝试:按 process group 发 SIGTERM。要求 spawn 时 detached:true。
|
|
82
|
+
trySignal(-pid, "SIGTERM");
|
|
83
|
+
trySignal(pid, "SIGTERM");
|
|
84
|
+
// 给进程 1 秒优雅退出机会
|
|
85
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
86
|
+
// 兜底:SIGKILL
|
|
87
|
+
trySignal(-pid, "SIGKILL");
|
|
88
|
+
trySignal(pid, "SIGKILL");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function trySignal(target: number, signal: NodeJS.Signals): void {
|
|
92
|
+
try {
|
|
93
|
+
process.kill(target, signal);
|
|
94
|
+
} catch {
|
|
95
|
+
// 进程已不存在或权限不足,忽略
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -137,6 +137,8 @@ export interface DisplayCardState {
|
|
|
137
137
|
lastSentAccLen?: number;
|
|
138
138
|
/** WeChat delta: 上次发送时的 finalReply */
|
|
139
139
|
lastSentFinalReply?: string;
|
|
140
|
+
/** 上次 loop 读取到的 turnCount,用于检测轮次切换 */
|
|
141
|
+
lastTurnCount?: number;
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
export const displayCards = new Map<string, DisplayCardState>();
|
package/src/session.ts
CHANGED
|
@@ -898,9 +898,13 @@ export async function runAgentSession(
|
|
|
898
898
|
setQueuePreservedChat(sessionId, preservedChat);
|
|
899
899
|
}
|
|
900
900
|
console.log(`[${ts()}] [QUEUE] Consuming queued message for session ${sessionId}: "${queued.text.slice(0, 50)}"`);
|
|
901
|
-
setImmediate
|
|
901
|
+
// setTimeout 而非 setImmediate:给 display loop 的 setInterval
|
|
902
|
+
// 足够时间读到 "done" 状态并终结旧卡片,避免新轮更新旧卡片的 bug。
|
|
903
|
+
// setImmediate 在 check 阶段触发早于下一个 timers 阶段,
|
|
904
|
+
// display loop (setInterval) 还没机会读到 "done" 就被新 "running" 覆盖。
|
|
905
|
+
setTimeout(() => {
|
|
902
906
|
consumeQueuedMessage(platform, queued);
|
|
903
|
-
});
|
|
907
|
+
}, 200);
|
|
904
908
|
}
|
|
905
909
|
}
|
|
906
910
|
|
|
@@ -1103,11 +1107,44 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1103
1107
|
cardCreatedAt: Date.now(),
|
|
1104
1108
|
lastSentContent: "",
|
|
1105
1109
|
streamErrorNotified: false,
|
|
1110
|
+
lastTurnCount: state.turnCount,
|
|
1106
1111
|
});
|
|
1107
1112
|
}
|
|
1108
1113
|
} else {
|
|
1109
1114
|
if (display.cardBusy) return;
|
|
1110
1115
|
|
|
1116
|
+
// 检测轮次切换:turnCount 变化说明上一轮已完成、本 tick 是新轮,
|
|
1117
|
+
// 但上一轮的 display 卡片因 setImmediate/setTimeout 时序问题
|
|
1118
|
+
// 还没被终结分支清除。此处主动终结旧卡、创建新卡。
|
|
1119
|
+
if (display.lastTurnCount !== undefined && display.lastTurnCount !== state.turnCount) {
|
|
1120
|
+
display.cardBusy = true;
|
|
1121
|
+
try {
|
|
1122
|
+
const doneSeq = display.sequence + 1;
|
|
1123
|
+
const doneCard = buildProgressCard("", { showStop: false, headerTitle: "完成" });
|
|
1124
|
+
await p.cardUpdate(display.cardId, doneCard, doneSeq).catch(() => {});
|
|
1125
|
+
displayCards.delete(chatId);
|
|
1126
|
+
} catch (_) { /* ignore */ }
|
|
1127
|
+
display.cardBusy = false;
|
|
1128
|
+
// 新建卡片
|
|
1129
|
+
const cardId = await p.cardCreate(buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch(() => null);
|
|
1130
|
+
if (cardId) {
|
|
1131
|
+
await p.cardSend(chatId, cardId).catch(() => null);
|
|
1132
|
+
displayCards.set(chatId, {
|
|
1133
|
+
cardId,
|
|
1134
|
+
sequence: 1,
|
|
1135
|
+
cardBusy: false,
|
|
1136
|
+
cardCreatedAt: Date.now(),
|
|
1137
|
+
lastSentContent: "",
|
|
1138
|
+
streamErrorNotified: false,
|
|
1139
|
+
lastTurnCount: state.turnCount,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// 更新当前 turn 的 lastTurnCount
|
|
1146
|
+
display.lastTurnCount = state.turnCount;
|
|
1147
|
+
|
|
1111
1148
|
// 卡片轮转
|
|
1112
1149
|
if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
|
|
1113
1150
|
display.cardBusy = true;
|
|
@@ -1213,7 +1250,18 @@ export function ensureDisplayLoop(sessionId: string): void {
|
|
|
1213
1250
|
// ---------------------------------------------------------------------------
|
|
1214
1251
|
// stopSession — 停止指定 session 的活跃 prompt
|
|
1215
1252
|
// ---------------------------------------------------------------------------
|
|
1216
|
-
|
|
1253
|
+
//
|
|
1254
|
+
// 设计要点:
|
|
1255
|
+
// 1) controller.abort() 触发 adapter finally 里的 killProcessTree(proc.pid),
|
|
1256
|
+
// 后者负责把整棵 CLI 进程树(cmd.exe 壳 + node CLI 入口 + 真二进制)一起
|
|
1257
|
+
// 收尸;之前用 proc.kill() 在 Windows + shell:true 下只能杀第一层 cmd.exe,
|
|
1258
|
+
// 会留下"幽灵 CLI 子进程"继续跑、stream-state 永远停在 running。
|
|
1259
|
+
//
|
|
1260
|
+
// 2) 立刻 fire-and-forget 把 stream-state 标 stopped,不依赖 runAgentSession
|
|
1261
|
+
// 的 finally。原因:generator 自然结束依赖子进程 stdout 关闭,killProcessTree
|
|
1262
|
+
// 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
|
|
1263
|
+
// 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
|
|
1264
|
+
// finally 后续再写一次也不冲突——status 最终值仍然是 stopped。
|
|
1217
1265
|
export function stopSession(sessionId: string): boolean {
|
|
1218
1266
|
const prompt = activePrompts.get(sessionId);
|
|
1219
1267
|
if (!prompt) return false;
|
|
@@ -1221,6 +1269,27 @@ export function stopSession(sessionId: string): boolean {
|
|
|
1221
1269
|
cancelQueuedMessage(sessionId);
|
|
1222
1270
|
prompt.controller.abort();
|
|
1223
1271
|
console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
|
|
1272
|
+
|
|
1273
|
+
// fire-and-forget:立刻把 stream-state.status 改成 stopped,
|
|
1274
|
+
// 让 display loop 下一次扫到立刻渲染"已停止"卡片,不必再等几秒。
|
|
1275
|
+
void (async () => {
|
|
1276
|
+
try {
|
|
1277
|
+
const current = await readStreamState(sessionId);
|
|
1278
|
+
if (!current) return;
|
|
1279
|
+
// 已经是终态就别再覆盖,避免把 done/error 误改成 stopped
|
|
1280
|
+
if (current.status !== "running") return;
|
|
1281
|
+
await writeStreamState({
|
|
1282
|
+
...current,
|
|
1283
|
+
status: "stopped",
|
|
1284
|
+
updatedAt: Date.now(),
|
|
1285
|
+
});
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
console.warn(
|
|
1288
|
+
`[${ts()}] [STOP] writeStreamState(stopped) failed for ${sessionId}: ${(err as Error).message}`,
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
})();
|
|
1292
|
+
|
|
1224
1293
|
return true;
|
|
1225
1294
|
}
|
|
1226
1295
|
|