chatccc 0.2.115 → 0.2.117
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
CHANGED
|
@@ -458,6 +458,12 @@ class ClaudeAdapter implements ToolAdapter {
|
|
|
458
458
|
|
|
459
459
|
const normalized = normalizeSdkMessage(raw);
|
|
460
460
|
if (normalized) yield normalized;
|
|
461
|
+
|
|
462
|
+
// result 是流末事件,收到后立即结束进程,防止 CLI 僵死导致 readline 挂起。
|
|
463
|
+
if (raw.type === "result") {
|
|
464
|
+
void killProcessTree(proc.pid);
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
461
467
|
}
|
|
462
468
|
} finally {
|
|
463
469
|
signal?.removeEventListener("abort", onAbort);
|
|
@@ -394,6 +394,12 @@ class CursorAdapter implements ToolAdapter {
|
|
|
394
394
|
}
|
|
395
395
|
const normalized = normalizeCursorMessage(raw);
|
|
396
396
|
if (normalized) yield normalized;
|
|
397
|
+
|
|
398
|
+
// result 是流末事件,收到后立即结束进程,防止 CLI 僵死导致 readline 挂起。
|
|
399
|
+
if (raw.type === "result") {
|
|
400
|
+
void killProcessTree(proc.pid);
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
397
403
|
}
|
|
398
404
|
} finally {
|
|
399
405
|
signal?.removeEventListener("abort", onAbort);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// resource-monitor.ts — CLI 进程资源监控(CPU + 内存)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// 对所有 chatccc 启动的 CLI 进程持续监控 CPU 和内存占用。
|
|
5
|
+
// 若连续 3 分钟两项指标均无变化,判定为僵死,发出 "stuck" 事件。
|
|
6
|
+
// =============================================================================
|
|
7
|
+
|
|
8
|
+
import { exec } from "node:child_process";
|
|
9
|
+
import { EventEmitter } from "node:events";
|
|
10
|
+
|
|
11
|
+
const CHECK_INTERVAL_MS = 30_000; // 30 秒检查一次
|
|
12
|
+
const STUCK_THRESHOLD = 6; // 连续 6 次无变化 = 3 分钟
|
|
13
|
+
|
|
14
|
+
interface ProcessSnapshot {
|
|
15
|
+
cpu: number;
|
|
16
|
+
memory: number;
|
|
17
|
+
unchangedCount: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface TrackedProcess {
|
|
21
|
+
pid: number;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
snapshot: ProcessSnapshot;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const resourceMonitor = new EventEmitter();
|
|
27
|
+
|
|
28
|
+
const tracked = new Map<number, TrackedProcess>();
|
|
29
|
+
|
|
30
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
|
|
32
|
+
function startIfNeeded(): void {
|
|
33
|
+
if (timer) return;
|
|
34
|
+
timer = setInterval(checkAll, CHECK_INTERVAL_MS);
|
|
35
|
+
timer.unref?.();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stopIfIdle(): void {
|
|
39
|
+
if (tracked.size > 0) return;
|
|
40
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerProcess(pid: number, sessionId: string): void {
|
|
44
|
+
tracked.set(pid, { pid, sessionId, snapshot: { cpu: -1, memory: -1, unchangedCount: 0 } });
|
|
45
|
+
startIfNeeded();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function unregisterProcess(pid: number): void {
|
|
49
|
+
tracked.delete(pid);
|
|
50
|
+
stopIfIdle();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// 批量查询进程指标(Windows PowerShell)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function execPowerShell(script: string, timeoutMs: number): Promise<string> {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
exec(
|
|
60
|
+
`powershell -NoProfile -Command "${script}"`,
|
|
61
|
+
{ timeout: timeoutMs, windowsHide: true },
|
|
62
|
+
(err, stdout) => {
|
|
63
|
+
if (err) reject(err);
|
|
64
|
+
else resolve(stdout);
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function getProcessMetrics(pids: number[]): Promise<Map<number, { cpu: number; memory: number }>> {
|
|
71
|
+
const result = new Map<number, { cpu: number; memory: number }>();
|
|
72
|
+
if (pids.length === 0) return result;
|
|
73
|
+
|
|
74
|
+
const psScript = `Get-Process -Id ${pids.join(",")} -ErrorAction SilentlyContinue | ForEach-Object { "$($_.Id)|$($_.CPU)|$($_.WorkingSet64)" }`;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const stdout = await execPowerShell(psScript, 10_000);
|
|
78
|
+
for (const line of stdout.trim().split(/\r?\n/)) {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (!trimmed) continue;
|
|
81
|
+
const [idStr, cpuStr, memStr] = trimmed.split("|");
|
|
82
|
+
const id = parseInt(idStr, 10);
|
|
83
|
+
const cpu = parseFloat(cpuStr);
|
|
84
|
+
const memory = parseInt(memStr, 10);
|
|
85
|
+
if (!isNaN(id) && !isNaN(cpu) && !isNaN(memory)) {
|
|
86
|
+
result.set(id, { cpu, memory });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// PowerShell 查询失败时跳过本轮,下次重试
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// 定时检查
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
async function checkAll(): Promise<void> {
|
|
101
|
+
if (tracked.size === 0) return;
|
|
102
|
+
|
|
103
|
+
const pids = [...tracked.keys()];
|
|
104
|
+
const metrics = await getProcessMetrics(pids);
|
|
105
|
+
|
|
106
|
+
for (const [pid, tp] of tracked) {
|
|
107
|
+
const m = metrics.get(pid);
|
|
108
|
+
if (!m) {
|
|
109
|
+
// 进程已不存在,停止追踪
|
|
110
|
+
tracked.delete(pid);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const prev = tp.snapshot;
|
|
115
|
+
const cpuChanged = m.cpu !== prev.cpu;
|
|
116
|
+
// 内存允许 ±1% 波动,避免正常抖动触发误判
|
|
117
|
+
const memTolerance = prev.memory > 0 ? Math.max(prev.memory * 0.01, 1024 * 1024) : 1024 * 1024;
|
|
118
|
+
const memChanged = Math.abs(m.memory - prev.memory) > memTolerance;
|
|
119
|
+
|
|
120
|
+
if (!cpuChanged && !memChanged) {
|
|
121
|
+
tp.snapshot.unchangedCount++;
|
|
122
|
+
if (tp.snapshot.unchangedCount >= STUCK_THRESHOLD) {
|
|
123
|
+
const idleMinutes = Math.round(
|
|
124
|
+
(tp.snapshot.unchangedCount * CHECK_INTERVAL_MS) / 60_000,
|
|
125
|
+
);
|
|
126
|
+
resourceMonitor.emit("stuck", {
|
|
127
|
+
pid: tp.pid,
|
|
128
|
+
sessionId: tp.sessionId,
|
|
129
|
+
idleMinutes,
|
|
130
|
+
});
|
|
131
|
+
tracked.delete(pid);
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
tp.snapshot.unchangedCount = 0;
|
|
135
|
+
}
|
|
136
|
+
tp.snapshot.cpu = m.cpu;
|
|
137
|
+
tp.snapshot.memory = m.memory;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
stopIfIdle();
|
|
141
|
+
}
|
|
@@ -71,6 +71,8 @@ export interface ActivePrompt {
|
|
|
71
71
|
/** Set when the watchdog detects that the CLI process disappeared before stream finalization. */
|
|
72
72
|
abnormalExit?: boolean;
|
|
73
73
|
abnormalExitNotified?: boolean;
|
|
74
|
+
/** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
|
|
75
|
+
resourceStuck?: boolean;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
export const activePrompts = new Map<string, ActivePrompt>();
|
|
@@ -149,6 +151,8 @@ export interface DisplayCardState {
|
|
|
149
151
|
lastSentFinalReply?: string;
|
|
150
152
|
/** 点点点动画计数器(统一 display loop 每个卡片独立计数) */
|
|
151
153
|
dotCount: number;
|
|
154
|
+
/** stream-state 连续读取失败次数,超过阈值才删除 display entry */
|
|
155
|
+
readFailCount?: number;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
export const displayCards = new Map<string, DisplayCardState>();
|
package/src/session.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type { ToolProcessInfo } from "./adapters/adapter-interface.ts";
|
|
|
29
29
|
import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
|
|
30
30
|
import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
|
|
31
31
|
import { createCodexAdapter } from "./adapters/codex-adapter.ts";
|
|
32
|
+
import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/resource-monitor.ts";
|
|
32
33
|
import { buildImSkillsPromptCached, exportSkillSubDocs, clearImSkillsPromptCache } from "./im-skills.ts";
|
|
33
34
|
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
34
35
|
|
|
@@ -181,7 +182,7 @@ function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): vo
|
|
|
181
182
|
clearPromptProcessMonitor(sessionId);
|
|
182
183
|
return;
|
|
183
184
|
}
|
|
184
|
-
if (current.stopped || current.abnormalExit) return;
|
|
185
|
+
if (current.stopped || current.abnormalExit || current.resourceStuck) return;
|
|
185
186
|
if (isProcessAliveImpl(info.pid)) return;
|
|
186
187
|
|
|
187
188
|
current.abnormalExit = true;
|
|
@@ -877,6 +878,26 @@ export async function runAgentSession(
|
|
|
877
878
|
startTime: now,
|
|
878
879
|
});
|
|
879
880
|
|
|
881
|
+
// 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
|
|
882
|
+
const onResourceStuck = (data: { pid: number; sessionId: string; idleMinutes: number }) => {
|
|
883
|
+
if (data.sessionId !== sessionId) return;
|
|
884
|
+
const prompt = activePrompts.get(sessionId);
|
|
885
|
+
if (!prompt || prompt.stopped || prompt.abnormalExit || prompt.resourceStuck) return;
|
|
886
|
+
prompt.resourceStuck = true;
|
|
887
|
+
|
|
888
|
+
const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
|
|
889
|
+
const p = chatId ? platformForChat(chatId) : null;
|
|
890
|
+
if (chatId && p) {
|
|
891
|
+
p.sendText(
|
|
892
|
+
chatId,
|
|
893
|
+
`⚠️ 会话僵死:session ${sessionId.slice(0, 8)} 对应的 CLI 进程 PID ${data.pid} CPU 和内存连续 ${data.idleMinutes} 分钟无变化,已强制停止。若回复不完整,请重新发送上一条指令。`,
|
|
894
|
+
).catch(() => {});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
controller.abort();
|
|
898
|
+
};
|
|
899
|
+
resourceMonitor.on("stuck", onResourceStuck);
|
|
900
|
+
|
|
880
901
|
// 异步准备工作(session info、IM skills prompt 等)
|
|
881
902
|
let adapter: ToolAdapter;
|
|
882
903
|
let info: Awaited<ReturnType<ToolAdapter["getSessionInfo"]>>;
|
|
@@ -1079,9 +1100,11 @@ export async function runAgentSession(
|
|
|
1079
1100
|
for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
|
|
1080
1101
|
onProcessStart: (processInfo) => {
|
|
1081
1102
|
startPromptProcessMonitor(sessionId, processInfo);
|
|
1103
|
+
if (processInfo.pid !== undefined) registerProcess(processInfo.pid, sessionId);
|
|
1082
1104
|
},
|
|
1083
|
-
onProcessExit: () => {
|
|
1105
|
+
onProcessExit: (exitInfo) => {
|
|
1084
1106
|
clearPromptProcessMonitor(sessionId);
|
|
1107
|
+
if (exitInfo.pid !== undefined) unregisterProcess(exitInfo.pid);
|
|
1085
1108
|
},
|
|
1086
1109
|
})) {
|
|
1087
1110
|
for (const block of unifiedMsg.blocks) {
|
|
@@ -1124,9 +1147,11 @@ export async function runAgentSession(
|
|
|
1124
1147
|
console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
|
|
1125
1148
|
} finally {
|
|
1126
1149
|
// 标记 prompt 结束
|
|
1150
|
+
resourceMonitor.off("stuck", onResourceStuck);
|
|
1127
1151
|
const prompt = activePrompts.get(sessionId);
|
|
1128
1152
|
const wasStopped = prompt?.stopped ?? false;
|
|
1129
1153
|
const wasAbnormalExit = prompt?.abnormalExit ?? false;
|
|
1154
|
+
const wasResourceStuck = prompt?.resourceStuck ?? false;
|
|
1130
1155
|
clearPromptProcessMonitor(sessionId);
|
|
1131
1156
|
activePrompts.delete(sessionId);
|
|
1132
1157
|
|
|
@@ -1134,7 +1159,7 @@ export async function runAgentSession(
|
|
|
1134
1159
|
// 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
|
|
1135
1160
|
// writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
|
|
1136
1161
|
// 运行中并更新旧卡片,而不是新建卡片。
|
|
1137
|
-
const finalStatus = wasAbnormalExit ? "error" : wasStopped ? "stopped" : "done";
|
|
1162
|
+
const finalStatus = (wasAbnormalExit || wasResourceStuck) ? "error" : wasStopped ? "stopped" : "done";
|
|
1138
1163
|
const finalReply = pickFinalReply(state).trim();
|
|
1139
1164
|
await writeStreamState({
|
|
1140
1165
|
sessionId,
|
|
@@ -1254,15 +1279,21 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1254
1279
|
const sessionId = display.sessionId;
|
|
1255
1280
|
const state = await readStreamState(sessionId);
|
|
1256
1281
|
if (!state) {
|
|
1257
|
-
|
|
1282
|
+
display.readFailCount = (display.readFailCount ?? 0) + 1;
|
|
1283
|
+
if (display.readFailCount >= 3) {
|
|
1284
|
+
console.log(`[${ts()}] [DISPLAY] readStreamState ${display.readFailCount} consecutive failures for ${sessionId}, removing display entry for chat ${chatId}`);
|
|
1285
|
+
displayCards.delete(chatId);
|
|
1286
|
+
}
|
|
1258
1287
|
continue;
|
|
1259
1288
|
}
|
|
1289
|
+
display.readFailCount = 0;
|
|
1260
1290
|
|
|
1261
1291
|
// 交叉验证:chat 当前绑定的 session 是否仍是 display 记录的 session。
|
|
1262
1292
|
// 若 chat 已被切换到其他 session(如 /newh),旧 display 必须停推。
|
|
1263
1293
|
const currentSessionForChat = sessionInfoMap.get(chatId)?.sessionId;
|
|
1264
1294
|
if (currentSessionForChat && currentSessionForChat !== sessionId) {
|
|
1265
1295
|
if (state.status !== "running") {
|
|
1296
|
+
console.log(`[${ts()}] [DISPLAY] chat ${chatId} now bound to ${currentSessionForChat}, removing stale display for ${sessionId} (terminal: ${state.status})`);
|
|
1266
1297
|
displayCards.delete(chatId);
|
|
1267
1298
|
}
|
|
1268
1299
|
continue;
|
|
@@ -1272,6 +1303,7 @@ export function startUnifiedDisplayLoop(): void {
|
|
|
1272
1303
|
const lastActive = getLastActiveChat(sessionId);
|
|
1273
1304
|
if (lastActive !== chatId) {
|
|
1274
1305
|
if (state.status !== "running") {
|
|
1306
|
+
console.log(`[${ts()}] [DISPLAY] lastActive mismatch for ${sessionId}: display chat=${chatId} lastActive=${lastActive ?? "undefined"}, removing display entry (terminal: ${state.status})`);
|
|
1275
1307
|
displayCards.delete(chatId);
|
|
1276
1308
|
}
|
|
1277
1309
|
continue;
|