chatccc 0.2.49 → 0.2.51
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__/cards.test.ts +9 -1
- package/src/__tests__/session.test.ts +426 -2
- package/src/__tests__/stream-state.test.ts +134 -0
- package/src/cards.ts +2 -2
- package/src/index.ts +125 -73
- package/src/session-chat-binding.ts +23 -0
- package/src/session.ts +208 -9
- package/src/shared.ts +95 -68
- package/src/stream-state.ts +140 -93
package/src/shared.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
appendFileSync,
|
|
4
|
-
existsSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
unlinkSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from "node:fs";
|
|
10
|
-
import { createServer } from "node:http";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { inspect } from "node:util";
|
|
14
|
-
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { inspect } from "node:util";
|
|
14
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
15
15
|
|
|
16
16
|
import { printServiceDidNotStart } from "./exit-banner.ts";
|
|
17
17
|
|
|
@@ -42,8 +42,11 @@ export function appendStartupTrace(message: string, extra?: Record<string, unkno
|
|
|
42
42
|
// 进程树:当前进程及其所有祖先(用于避免 taskkill /T 误杀启动链)
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
let _cachedAncestorSet: Set<number> | undefined;
|
|
46
|
+
|
|
47
|
+
/** 自当前 PID 沿父链上溯直到系统进程,包含自身。结果会被缓存,同一进程内多次调用复用。 */
|
|
46
48
|
export function getProcessAncestorPidSet(): Set<number> {
|
|
49
|
+
if (_cachedAncestorSet) return _cachedAncestorSet;
|
|
47
50
|
const ancestors = new Set<number>();
|
|
48
51
|
let cur: number = process.pid;
|
|
49
52
|
const maxHops = 48;
|
|
@@ -74,6 +77,7 @@ export function getProcessAncestorPidSet(): Set<number> {
|
|
|
74
77
|
if (parent === undefined || Number.isNaN(parent) || parent === cur) break;
|
|
75
78
|
cur = parent;
|
|
76
79
|
}
|
|
80
|
+
_cachedAncestorSet = ancestors;
|
|
77
81
|
return ancestors;
|
|
78
82
|
}
|
|
79
83
|
|
|
@@ -137,8 +141,9 @@ function collectListeningPidsOnPortWindows(port: number, netstatOut: string): nu
|
|
|
137
141
|
return out;
|
|
138
142
|
}
|
|
139
143
|
|
|
140
|
-
/** 在 createRelayServer
|
|
141
|
-
|
|
144
|
+
/** 在 createRelayServer 之前调用:清掉本机该端口上仍在监听的旧进程(不杀当前进程树祖先)。
|
|
145
|
+
* 返回实际 kill 的进程数。 */
|
|
146
|
+
export function freeRelayListenPort(port: number): number {
|
|
142
147
|
const ancestors = getProcessAncestorPidSet();
|
|
143
148
|
appendStartupTrace("freeRelayListenPort: begin", {
|
|
144
149
|
port,
|
|
@@ -147,9 +152,10 @@ export function freeRelayListenPort(port: number): void {
|
|
|
147
152
|
|
|
148
153
|
if (process.platform !== "win32") {
|
|
149
154
|
appendStartupTrace("freeRelayListenPort: skip (non-Windows)", { port });
|
|
150
|
-
return;
|
|
155
|
+
return 0;
|
|
151
156
|
}
|
|
152
157
|
|
|
158
|
+
let killed = 0;
|
|
153
159
|
try {
|
|
154
160
|
const portOut = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8", windowsHide: true });
|
|
155
161
|
const pids = collectListeningPidsOnPortWindows(port, portOut);
|
|
@@ -159,11 +165,32 @@ export function freeRelayListenPort(port: number): void {
|
|
|
159
165
|
console.log(`[KILL] Free port ${port}: killing LISTENING/UDP holder PID ${pidNum}...`);
|
|
160
166
|
appendStartupTrace("freeRelayListenPort: killByPid", { port, pid: pidNum });
|
|
161
167
|
killByPid(pidNum);
|
|
168
|
+
killed++;
|
|
162
169
|
}
|
|
163
170
|
} catch {
|
|
164
171
|
appendStartupTrace("freeRelayListenPort: netstat/findstr (no rows or error)", { port });
|
|
165
172
|
}
|
|
166
|
-
appendStartupTrace("freeRelayListenPort: end", { port });
|
|
173
|
+
appendStartupTrace("freeRelayListenPort: end", { port, killed });
|
|
174
|
+
return killed;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 轮询等待端口释放(Windows 下 taskkill 后端口可能不会立即释放)。
|
|
179
|
+
* 在 freeRelayListenPort kill 了进程后调用,确认端口真正空闲再 listen。
|
|
180
|
+
*/
|
|
181
|
+
export async function waitForPortFree(port: number, timeoutMs = 3000): Promise<void> {
|
|
182
|
+
if (process.platform !== "win32") return;
|
|
183
|
+
const deadline = Date.now() + timeoutMs;
|
|
184
|
+
while (Date.now() < deadline) {
|
|
185
|
+
try {
|
|
186
|
+
const out = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf8", windowsHide: true });
|
|
187
|
+
const pids = collectListeningPidsOnPortWindows(port, out);
|
|
188
|
+
if (pids.length === 0) return;
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
193
|
+
}
|
|
167
194
|
}
|
|
168
195
|
|
|
169
196
|
// ---------------------------------------------------------------------------
|
|
@@ -355,56 +382,56 @@ export function installCrashLogging(opts: CrashHandlersOptions = {}): InstallCra
|
|
|
355
382
|
// 文件日志:同时输出到控制台和日志文件
|
|
356
383
|
// ---------------------------------------------------------------------------
|
|
357
384
|
|
|
358
|
-
export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
|
|
359
|
-
mkdirSync(logDir, { recursive: true });
|
|
360
|
-
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
361
|
-
const logPath = join(logDir, `${prefix}-${ts}.log`);
|
|
362
|
-
writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
|
|
363
|
-
const origConsoleLog = console.log.bind(console);
|
|
364
|
-
const origConsoleError = console.error.bind(console);
|
|
365
|
-
const formatArg = (arg: unknown): string => {
|
|
366
|
-
if (typeof arg === "string") return arg;
|
|
367
|
-
if (arg instanceof Error) return arg.stack ?? arg.message;
|
|
368
|
-
return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
|
|
369
|
-
};
|
|
370
|
-
const writeLine = (level: string, args: unknown[]) => {
|
|
371
|
-
try {
|
|
372
|
-
const line = args.map(formatArg).join(" ");
|
|
373
|
-
appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
|
|
374
|
-
} catch (err) {
|
|
375
|
-
try {
|
|
376
|
-
appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
|
|
377
|
-
} catch {
|
|
378
|
-
// 日志系统自身不能影响主流程
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
};
|
|
382
|
-
console.log = (...args: unknown[]) => {
|
|
383
|
-
writeLine("LOG", args);
|
|
384
|
-
try {
|
|
385
|
-
origConsoleLog(...args);
|
|
386
|
-
} catch {
|
|
387
|
-
// 控制台输出失败也不能拖垮服务
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
console.error = (...args: unknown[]) => {
|
|
391
|
-
writeLine("ERR", args);
|
|
392
|
-
try {
|
|
393
|
-
origConsoleError(...args);
|
|
394
|
-
} catch {
|
|
395
|
-
// 控制台输出失败也不能拖垮服务
|
|
396
|
-
}
|
|
397
|
-
};
|
|
398
|
-
const flush = () => {
|
|
399
|
-
try {
|
|
400
|
-
appendFileSync(logPath, "", "utf8");
|
|
401
|
-
} catch (err) {
|
|
402
|
-
appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
|
|
403
|
-
}
|
|
404
|
-
};
|
|
405
|
-
origConsoleLog(`Log file: ${logPath}`);
|
|
406
|
-
return { logPath, flush };
|
|
407
|
-
}
|
|
385
|
+
export function setupFileLogging(logDir: string, prefix: string): { logPath: string; flush: () => void } {
|
|
386
|
+
mkdirSync(logDir, { recursive: true });
|
|
387
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
388
|
+
const logPath = join(logDir, `${prefix}-${ts}.log`);
|
|
389
|
+
writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
|
|
390
|
+
const origConsoleLog = console.log.bind(console);
|
|
391
|
+
const origConsoleError = console.error.bind(console);
|
|
392
|
+
const formatArg = (arg: unknown): string => {
|
|
393
|
+
if (typeof arg === "string") return arg;
|
|
394
|
+
if (arg instanceof Error) return arg.stack ?? arg.message;
|
|
395
|
+
return inspect(arg, { depth: 6, breakLength: Infinity, maxArrayLength: 200 });
|
|
396
|
+
};
|
|
397
|
+
const writeLine = (level: string, args: unknown[]) => {
|
|
398
|
+
try {
|
|
399
|
+
const line = args.map(formatArg).join(" ");
|
|
400
|
+
appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${line}\n`, "utf8");
|
|
401
|
+
} catch (err) {
|
|
402
|
+
try {
|
|
403
|
+
appendStartupTrace("fileLog write failed", { level, error: err instanceof Error ? err.message : String(err) });
|
|
404
|
+
} catch {
|
|
405
|
+
// 日志系统自身不能影响主流程
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
console.log = (...args: unknown[]) => {
|
|
410
|
+
writeLine("LOG", args);
|
|
411
|
+
try {
|
|
412
|
+
origConsoleLog(...args);
|
|
413
|
+
} catch {
|
|
414
|
+
// 控制台输出失败也不能拖垮服务
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
console.error = (...args: unknown[]) => {
|
|
418
|
+
writeLine("ERR", args);
|
|
419
|
+
try {
|
|
420
|
+
origConsoleError(...args);
|
|
421
|
+
} catch {
|
|
422
|
+
// 控制台输出失败也不能拖垮服务
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const flush = () => {
|
|
426
|
+
try {
|
|
427
|
+
appendFileSync(logPath, "", "utf8");
|
|
428
|
+
} catch (err) {
|
|
429
|
+
appendStartupTrace("fileLog flush failed", { error: err instanceof Error ? err.message : String(err) });
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
origConsoleLog(`Log file: ${logPath}`);
|
|
433
|
+
return { logPath, flush };
|
|
434
|
+
}
|
|
408
435
|
|
|
409
436
|
// ---------------------------------------------------------------------------
|
|
410
437
|
// 本地 WebSocket 中继服务器(同一端口、多客户端广播)
|
package/src/stream-state.ts
CHANGED
|
@@ -1,94 +1,141 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
|
|
4
|
-
import { USER_DATA_DIR, ts } from "./config.ts";
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// stream-state.json — 每个 session 的流式输出持久化文件
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
|
-
export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
|
|
11
|
-
|
|
12
|
-
export interface StreamState {
|
|
13
|
-
sessionId: string;
|
|
14
|
-
status: "running" | "done" | "stopped" | "error";
|
|
15
|
-
accumulatedContent: string;
|
|
16
|
-
finalReply: string;
|
|
17
|
-
chunkCount: number;
|
|
18
|
-
turnCount: number;
|
|
19
|
-
contextTokens: number;
|
|
20
|
-
updatedAt: number;
|
|
21
|
-
cwd: string;
|
|
22
|
-
tool: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getStreamStatePath(sessionId: string): string {
|
|
26
|
-
return join(STREAMS_DIR, `${sessionId}.json`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export async function readStreamState(sessionId: string): Promise<StreamState | null> {
|
|
30
|
-
try {
|
|
31
|
-
const raw = await readFile(getStreamStatePath(sessionId), "utf-8");
|
|
32
|
-
const parsed = JSON.parse(raw) as StreamState;
|
|
33
|
-
if (parsed && typeof parsed.sessionId === "string") return parsed;
|
|
34
|
-
return null;
|
|
35
|
-
} catch {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
1
|
+
import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { USER_DATA_DIR, ts } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// stream-state.json — 每个 session 的流式输出持久化文件
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
|
|
11
|
+
|
|
12
|
+
export interface StreamState {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
status: "running" | "done" | "stopped" | "error";
|
|
15
|
+
accumulatedContent: string;
|
|
16
|
+
finalReply: string;
|
|
17
|
+
chunkCount: number;
|
|
18
|
+
turnCount: number;
|
|
19
|
+
contextTokens: number;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
cwd: string;
|
|
22
|
+
tool: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getStreamStatePath(sessionId: string): string {
|
|
26
|
+
return join(STREAMS_DIR, `${sessionId}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readStreamState(sessionId: string): Promise<StreamState | null> {
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(getStreamStatePath(sessionId), "utf-8");
|
|
32
|
+
const parsed = JSON.parse(raw) as StreamState;
|
|
33
|
+
if (parsed && typeof parsed.sessionId === "string") return parsed;
|
|
34
|
+
return null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// rename 的可注入实现——仅供测试模拟"Windows EPERM 降级"路径。
|
|
41
|
+
// 生产代码使用 node:fs/promises.rename。
|
|
42
|
+
let renameImpl: typeof rename = rename;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 仅供单测:注入自定义 rename 实现以验证降级分支。
|
|
46
|
+
* 调用方负责测试结束后用 _resetRenameForTest 还原。
|
|
47
|
+
*/
|
|
48
|
+
export function _setRenameForTest(impl: typeof rename): void {
|
|
49
|
+
renameImpl = impl;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function _resetRenameForTest(): void {
|
|
53
|
+
renameImpl = rename;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 把 StreamState 持久化到磁盘——尽量原子地写。
|
|
58
|
+
*
|
|
59
|
+
* 为什么需要原子写:display loop 每 3 秒读一次 stream-state.json,而
|
|
60
|
+
* runAgentSession 每 2 秒写一次。如果用 `writeFile(filePath, ...)` 直接
|
|
61
|
+
* 覆盖,reader 在写入过程中读会拿到半截 JSON,JSON.parse 报错——虽然
|
|
62
|
+
* readStreamState 包了 try/catch 返回 null 不会崩,但相当于"丢一帧"。
|
|
63
|
+
*
|
|
64
|
+
* 实现:
|
|
65
|
+
* 1) 先把内容写到 `<filePath>.tmp`(reader 不会读 .tmp)
|
|
66
|
+
* 2) 再用 `rename` 把 .tmp 替换成正式文件——`rename` 在 POSIX 上是真原子
|
|
67
|
+
* 操作,reader 任何时刻读到的都是某个完整的旧/新版本
|
|
68
|
+
*
|
|
69
|
+
* Windows 兼容性:Windows 上 `rename` 偶尔会因为目标文件正被 reader 打开
|
|
70
|
+
* 抛 EPERM/EBUSY(实测罕见,但非零概率)。降级方案:直接 writeFile 覆盖
|
|
71
|
+
* 真文件,这一帧可能被 reader 读到半截,但 readStreamState 的 try/catch 兜底
|
|
72
|
+
* 会让 loop 跳过这一帧,2 秒后下次 write 大概率成功——比写盘失败丢数据好。
|
|
73
|
+
*/
|
|
74
|
+
export async function writeStreamState(state: StreamState): Promise<void> {
|
|
75
|
+
const filePath = getStreamStatePath(state.sessionId);
|
|
76
|
+
const payload = JSON.stringify(state, null, 2);
|
|
77
|
+
try {
|
|
78
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
79
|
+
const tmpPath = filePath + ".tmp";
|
|
80
|
+
await writeFile(tmpPath, payload, "utf-8");
|
|
81
|
+
try {
|
|
82
|
+
await renameImpl(tmpPath, filePath);
|
|
83
|
+
} catch (renameErr) {
|
|
84
|
+
// Windows 偶发 EPERM/EBUSY:rename 失败时降级为直接覆盖写。
|
|
85
|
+
// 此次写入失去原子性(reader 可能读到半截 JSON,但 readStreamState
|
|
86
|
+
// 的 try/catch 会兜底返回 null,只是丢一帧 display 更新)。
|
|
87
|
+
console.warn(
|
|
88
|
+
`[${ts()}] [STREAM-STATE] rename failed for ${state.sessionId}, ` +
|
|
89
|
+
`fallback to overwrite: ${(renameErr as Error).message}`,
|
|
90
|
+
);
|
|
91
|
+
await writeFile(filePath, payload, "utf-8");
|
|
92
|
+
// 尽力清理 .tmp,失败也无所谓——下次 write 会覆盖它
|
|
93
|
+
await unlink(tmpPath).catch(() => {});
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
|
|
101
|
+
return {
|
|
102
|
+
sessionId,
|
|
103
|
+
status: "running",
|
|
104
|
+
accumulatedContent: "",
|
|
105
|
+
finalReply: "",
|
|
106
|
+
chunkCount: 0,
|
|
107
|
+
turnCount,
|
|
108
|
+
contextTokens: 0,
|
|
109
|
+
updatedAt: Date.now(),
|
|
110
|
+
cwd,
|
|
111
|
+
tool,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 进程重启时修正虚假的 "running" 状态 */
|
|
116
|
+
export async function fixStaleStreamStates(): Promise<void> {
|
|
117
|
+
// 启动时将残留的 running 标记为 error
|
|
118
|
+
// 实际 agent 进程已不存在,下次 prompt 时会自然覆盖
|
|
119
|
+
try {
|
|
120
|
+
const { readdir } = await import("node:fs/promises");
|
|
121
|
+
let entries: string[];
|
|
122
|
+
try {
|
|
123
|
+
entries = await readdir(STREAMS_DIR);
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
if (!entry.endsWith(".json")) continue;
|
|
129
|
+
const state = await readStreamState(entry.replace(".json", ""));
|
|
130
|
+
if (state && state.status === "running") {
|
|
131
|
+
state.status = "error";
|
|
132
|
+
state.accumulatedContent += "\n\n⚠️ 进程重启,流式输出中断。";
|
|
133
|
+
state.updatedAt = Date.now();
|
|
134
|
+
await writeStreamState(state);
|
|
135
|
+
console.log(`[${ts()}] [STREAM-STATE] marked stale running as error: ${state.sessionId}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
|
|
140
|
+
}
|
|
94
141
|
}
|