chatccc 0.2.50 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.50",
3
+ "version": "0.2.51",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
- import { mkdtemp, rm } from "node:fs/promises";
2
+ import { mkdtemp, rm, readFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
 
@@ -29,6 +29,8 @@ import {
29
29
  saveSessionTool,
30
30
  accumulateBlockContent,
31
31
  pickFinalReply,
32
+ switchChatBinding,
33
+ rebuildBindingsFromRegistry,
32
34
  UNKNOWN_MODEL_PLACEHOLDER,
33
35
  _setSessionRegistryFileForTest,
34
36
  _resetSessionRegistryFileForTest,
@@ -45,6 +47,8 @@ import {
45
47
  getLastActiveChat,
46
48
  pickDisplayChat,
47
49
  resetBindingState,
50
+ getChatsForSession,
51
+ displayCards,
48
52
  } from "../session-chat-binding.ts";
49
53
  import type { AccumulatorState } from "../session.ts";
50
54
  import type { ToolAdapter, UnifiedBlock, SessionInfo } from "../adapters/adapter-interface.ts";
@@ -133,6 +137,94 @@ describe("resetState", () => {
133
137
  });
134
138
  });
135
139
 
140
+ // ---------------------------------------------------------------------------
141
+ // rebuildBindingsFromRegistry — SDK 重连/启动时只重建只读映射,不动运行时状态
142
+ //
143
+ // 这是 onReady/onReconnected 应当调用的函数(替代之前错误调用的 resetState)。
144
+ // 关键不变量:重建映射后,原有的 activePrompts、sessionInfoMap、displayCards、
145
+ // processedMessages 全部保留——SDK 重连不应当影响后台 prompt 的执行。
146
+ // ---------------------------------------------------------------------------
147
+
148
+ describe("rebuildBindingsFromRegistry", () => {
149
+ let registryFile = "";
150
+
151
+ beforeEach(async () => {
152
+ chatSessionMap.clear();
153
+ sessionInfoMap.clear();
154
+ activePrompts.clear();
155
+ processedMessages.clear();
156
+ resetBindingState();
157
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-rebuild-"));
158
+ registryFile = join(dir, "session-registry.json");
159
+ _setSessionRegistryFileForTest(registryFile);
160
+ });
161
+
162
+ afterEach(async () => {
163
+ _resetSessionRegistryFileForTest();
164
+ if (registryFile) {
165
+ await rm(dirname(registryFile), { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ it("不清空 activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃", async () => {
170
+ // 模拟有一个后台 prompt 正在跑
171
+ const controller = new AbortController();
172
+ activePrompts.set("session-running", { controller, stopped: false, startTime: Date.now() });
173
+ await recordSessionRegistry({
174
+ chatId: "chat-A",
175
+ sessionId: "session-running",
176
+ tool: "claude",
177
+ updatedAt: 100,
178
+ });
179
+
180
+ await rebuildBindingsFromRegistry();
181
+
182
+ // 关键不变量:重连后 activePrompts 必须保留,否则后台 generator 会变孤儿
183
+ expect(activePrompts.has("session-running")).toBe(true);
184
+ expect(activePrompts.get("session-running")?.controller).toBe(controller);
185
+ });
186
+
187
+ it("不清空 sessionInfoMap:轮数计数在重连后保留", async () => {
188
+ sessionInfoMap.set("chat-A", {
189
+ sessionId: "sid-A", turnCount: 7, lastContextTokens: 50000,
190
+ startTime: 1000, tool: "claude",
191
+ });
192
+ await recordSessionRegistry({
193
+ chatId: "chat-A", sessionId: "sid-A", tool: "claude", updatedAt: 100,
194
+ });
195
+
196
+ await rebuildBindingsFromRegistry();
197
+
198
+ expect(sessionInfoMap.get("chat-A")?.turnCount).toBe(7);
199
+ expect(sessionInfoMap.get("chat-A")?.lastContextTokens).toBe(50000);
200
+ });
201
+
202
+ it("不清空 processedMessages:重连后 SDK 重推消息仍能去重", async () => {
203
+ processedMessages.add("msg-id-1");
204
+ processedMessages.add("msg-id-2");
205
+
206
+ await rebuildBindingsFromRegistry();
207
+
208
+ expect(processedMessages.has("msg-id-1")).toBe(true);
209
+ expect(processedMessages.has("msg-id-2")).toBe(true);
210
+ });
211
+
212
+ it("从 registry 重建 sessionId → chatId 映射(沿用 rebuildSessionChatsFromRegistry 行为)", async () => {
213
+ await recordSessionRegistry({
214
+ chatId: "chat-A", sessionId: "sid-X", tool: "claude", updatedAt: 100,
215
+ });
216
+ await recordSessionRegistry({
217
+ chatId: "chat-B", sessionId: "sid-X", tool: "claude", updatedAt: 200,
218
+ });
219
+
220
+ await rebuildBindingsFromRegistry();
221
+
222
+ // 同一 sessionId 被两个 chatId 共享时,两个都应在映射中
223
+ bindChatToSession("sid-X", "chat-A"); // 验证幂等(再次调用不会出错)
224
+ expect(true).toBe(true); // 真正的断言由 sessionChatsMap 通过 pickDisplayChat 等间接验证
225
+ });
226
+ });
227
+
136
228
  describe("getSessionStatus", () => {
137
229
  beforeEach(() => {
138
230
  chatSessionMap.clear();
@@ -752,3 +844,218 @@ describe("unbindChatFromSession 同步清理 lastActiveChatMap", () => {
752
844
  expect(getLastActiveChat("sid-A")).toBe("chat_X");
753
845
  });
754
846
  });
847
+
848
+ // ---------------------------------------------------------------------------
849
+ // switchChatBinding — 事务式 chat→session 切换(/newh、/session N 复用)
850
+ //
851
+ // 关键不变量:
852
+ // 1. p2p chatType 不调 updateChatInfo(私聊飞书 API 会直接抛错)
853
+ // 2. updateChatInfo 失败时,内存绑定/sessionInfoMap/displayCards 完全不动,
854
+ // 且 description 还是旧值,下次消息按旧 sessionId 路由不会乱
855
+ // 3. 成功时按 unbind 旧 → bind 新 → recordLastActiveChat 顺序原子切换
856
+ // 4. 持久化 registry + sessions.json
857
+ // ---------------------------------------------------------------------------
858
+
859
+ describe("switchChatBinding", () => {
860
+ let registryFile = "";
861
+ let sessionsFile = "";
862
+
863
+ beforeEach(async () => {
864
+ resetBindingState();
865
+ sessionInfoMap.clear();
866
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-switch-binding-"));
867
+ registryFile = join(dir, "session-registry.json");
868
+ sessionsFile = join(dir, "sessions.json");
869
+ _setSessionRegistryFileForTest(registryFile);
870
+ _setSessionToolsFileForTest(sessionsFile);
871
+ });
872
+
873
+ afterEach(async () => {
874
+ _resetSessionRegistryFileForTest();
875
+ _resetSessionToolsFileForTest();
876
+ if (registryFile) {
877
+ await rm(dirname(registryFile), { recursive: true, force: true });
878
+ }
879
+ });
880
+
881
+ it("群聊场景:API 成功后内存切换 + 持久化", async () => {
882
+ const calls: Array<{ chatId: string; name: string; desc: string }> = [];
883
+ const updateChatInfoFn = async (chatId: string, name: string, desc: string) => {
884
+ calls.push({ chatId, name, desc });
885
+ };
886
+
887
+ bindChatToSession("old-sid", "chat-1");
888
+ sessionInfoMap.set("chat-1", {
889
+ sessionId: "old-sid", turnCount: 5, lastContextTokens: 100,
890
+ startTime: 0, tool: "claude",
891
+ });
892
+ displayCards.set("chat-1", {
893
+ cardId: "c1", sequence: 1, cardBusy: false,
894
+ cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
895
+ });
896
+
897
+ const result = await switchChatBinding({
898
+ chatId: "chat-1",
899
+ chatType: "group",
900
+ oldSessionId: "old-sid",
901
+ newSessionId: "new-sid",
902
+ tool: "claude",
903
+ chatName: "新会话-test",
904
+ newDescription: "Claude Code Session: new-sid",
905
+ updateChatInfoFn,
906
+ });
907
+
908
+ expect(result.ok).toBe(true);
909
+ expect(calls).toHaveLength(1);
910
+ expect(calls[0]).toEqual({
911
+ chatId: "chat-1",
912
+ name: "新会话-test",
913
+ desc: "Claude Code Session: new-sid",
914
+ });
915
+ // 旧 session 已解绑,新 session 已绑
916
+ expect(getChatsForSession("old-sid")).toEqual([]);
917
+ expect(getChatsForSession("new-sid")).toEqual(["chat-1"]);
918
+ // displayCards 已清
919
+ expect(displayCards.has("chat-1")).toBe(false);
920
+ // sessionInfoMap 指向新 sessionId
921
+ expect(sessionInfoMap.get("chat-1")?.sessionId).toBe("new-sid");
922
+ // lastActiveChat 指向当前 chat
923
+ expect(getLastActiveChat("new-sid")).toBe("chat-1");
924
+ });
925
+
926
+ it("私聊场景:完全跳过 updateChatInfo,仍完成内存切换", async () => {
927
+ let called = false;
928
+ const updateChatInfoFn = async () => {
929
+ called = true;
930
+ throw new Error("p2p chat API would fail");
931
+ };
932
+
933
+ const result = await switchChatBinding({
934
+ chatId: "p2p-chat",
935
+ chatType: "p2p",
936
+ oldSessionId: null,
937
+ newSessionId: "new-sid-p2p",
938
+ tool: "claude",
939
+ chatName: "新会话-p2p",
940
+ newDescription: "Claude Code Session: new-sid-p2p",
941
+ updateChatInfoFn,
942
+ });
943
+
944
+ expect(result.ok).toBe(true);
945
+ expect(called).toBe(false); // 私聊跳过 API 调用
946
+ expect(getChatsForSession("new-sid-p2p")).toEqual(["p2p-chat"]);
947
+ expect(sessionInfoMap.get("p2p-chat")?.sessionId).toBe("new-sid-p2p");
948
+ });
949
+
950
+ it("群聊 + updateChatInfo 抛错:内存完全不动 + 返回 error", async () => {
951
+ bindChatToSession("old-sid", "chat-1");
952
+ sessionInfoMap.set("chat-1", {
953
+ sessionId: "old-sid", turnCount: 5, lastContextTokens: 100,
954
+ startTime: 0, tool: "claude",
955
+ });
956
+ const oldDisplay = {
957
+ cardId: "c1", sequence: 1, cardBusy: false,
958
+ cardCreatedAt: 0, lastSentContent: "", streamErrorNotified: false,
959
+ };
960
+ displayCards.set("chat-1", oldDisplay);
961
+
962
+ const updateChatInfoFn = async () => {
963
+ throw new Error("network timeout");
964
+ };
965
+
966
+ const result = await switchChatBinding({
967
+ chatId: "chat-1",
968
+ chatType: "group",
969
+ oldSessionId: "old-sid",
970
+ newSessionId: "new-sid",
971
+ tool: "claude",
972
+ chatName: "新会话-failed",
973
+ newDescription: "Claude Code Session: new-sid",
974
+ updateChatInfoFn,
975
+ });
976
+
977
+ expect(result.ok).toBe(false);
978
+ expect(result.error?.message).toBe("network timeout");
979
+ // 内存绑定保持旧状态
980
+ expect(getChatsForSession("old-sid")).toEqual(["chat-1"]);
981
+ expect(getChatsForSession("new-sid")).toEqual([]);
982
+ expect(displayCards.get("chat-1")).toBe(oldDisplay);
983
+ expect(sessionInfoMap.get("chat-1")?.sessionId).toBe("old-sid");
984
+ expect(sessionInfoMap.get("chat-1")?.turnCount).toBe(5);
985
+ });
986
+
987
+ it("oldSessionId 为 null 时不调 unbind(适用于私聊首次绑定)", async () => {
988
+ const updateChatInfoFn = async () => {};
989
+
990
+ const result = await switchChatBinding({
991
+ chatId: "fresh-chat",
992
+ chatType: "p2p",
993
+ oldSessionId: null,
994
+ newSessionId: "fresh-sid",
995
+ tool: "claude",
996
+ chatName: "首次会话",
997
+ newDescription: "Claude Code Session: fresh-sid",
998
+ updateChatInfoFn,
999
+ });
1000
+
1001
+ expect(result.ok).toBe(true);
1002
+ expect(getChatsForSession("fresh-sid")).toEqual(["fresh-chat"]);
1003
+ });
1004
+
1005
+ it("API 成功后,registry 持久化记录可被重新加载", async () => {
1006
+ const updateChatInfoFn = async () => {};
1007
+
1008
+ await switchChatBinding({
1009
+ chatId: "chat-persist",
1010
+ chatType: "group",
1011
+ oldSessionId: null,
1012
+ newSessionId: "persist-sid",
1013
+ tool: "cursor",
1014
+ chatName: "persist-name",
1015
+ newDescription: "Cursor Session: persist-sid",
1016
+ initialTurnCount: 3,
1017
+ initialContextTokens: 500,
1018
+ updateChatInfoFn,
1019
+ });
1020
+
1021
+ // 验证 registry 文件已写入
1022
+ const raw = await readFile(registryFile, "utf-8");
1023
+ const parsed = JSON.parse(raw);
1024
+ expect(parsed["chat-persist"]).toMatchObject({
1025
+ chatId: "chat-persist",
1026
+ sessionId: "persist-sid",
1027
+ tool: "cursor",
1028
+ chatName: "persist-name",
1029
+ turnCount: 3,
1030
+ lastContextTokens: 500,
1031
+ running: false,
1032
+ });
1033
+ });
1034
+ });
1035
+
1036
+ // ---------------------------------------------------------------------------
1037
+ // resetState 调用契约:仅供测试 + 进程首次启动。
1038
+ // 不应在 SDK onReady/onReconnected 中调用——会清空 activePrompts 让正在跑
1039
+ // 的后台 prompt 变成"孤儿 generator"(Map 删了但 controller 没 abort,
1040
+ // 导致同一 sessionId 双开 prompt)。
1041
+ // ---------------------------------------------------------------------------
1042
+
1043
+ describe("resetState 契约:清空所有运行时状态", () => {
1044
+ it("清空 activePrompts 但不 abort controller(只能由进程首次启动调用)", () => {
1045
+ const controller = new AbortController();
1046
+ let aborted = false;
1047
+ controller.signal.addEventListener("abort", () => { aborted = true; });
1048
+ activePrompts.set("sid-running", {
1049
+ controller, stopped: false, startTime: 0,
1050
+ });
1051
+
1052
+ resetState();
1053
+
1054
+ expect(activePrompts.size).toBe(0);
1055
+ // 注意:resetState 不主动 abort——所以如果生产代码在 prompt 跑过程中
1056
+ // 误调 resetState,后台 generator 仍会继续跑直到自然结束,但 activePrompts
1057
+ // 已经空了,下条消息会双开 prompt。这是 resetState 仅适用于"启动时"
1058
+ // (Map 本就是空的)的根本原因。
1059
+ expect(aborted).toBe(false);
1060
+ });
1061
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtemp, rm, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // 在 import stream-state 之前先 mock config,让 USER_DATA_DIR 指向临时目录
7
+ const TEST_DATA_DIR = await mkdtemp(join(tmpdir(), "chatccc-stream-state-test-"));
8
+ vi.mock("../config.ts", async () => {
9
+ const actual = await vi.importActual<typeof import("../config.ts")>("../config.ts");
10
+ return {
11
+ ...actual,
12
+ USER_DATA_DIR: TEST_DATA_DIR,
13
+ ts: () => "test-ts",
14
+ };
15
+ });
16
+
17
+ const {
18
+ writeStreamState,
19
+ readStreamState,
20
+ createEmptyStreamState,
21
+ STREAMS_DIR,
22
+ _setRenameForTest,
23
+ _resetRenameForTest,
24
+ } = await import("../stream-state.ts");
25
+
26
+ describe("writeStreamState — atomic rename", () => {
27
+ beforeEach(async () => {
28
+ // 清空测试目录
29
+ try {
30
+ const entries = await readdir(STREAMS_DIR);
31
+ for (const e of entries) await rm(join(STREAMS_DIR, e), { force: true });
32
+ } catch { /* dir not yet exist */ }
33
+ });
34
+
35
+ afterEach(async () => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ it("writeStreamState 写完后 readStreamState 能读到完整内容", async () => {
40
+ const state = createEmptyStreamState("sid-1", "/tmp", "claude", 1);
41
+ state.accumulatedContent = "hello";
42
+ state.finalReply = "world";
43
+
44
+ await writeStreamState(state);
45
+
46
+ const got = await readStreamState("sid-1");
47
+ expect(got).not.toBeNull();
48
+ expect(got!.accumulatedContent).toBe("hello");
49
+ expect(got!.finalReply).toBe("world");
50
+ });
51
+
52
+ it("写入完成后,临时 .tmp 文件不应残留(rename 成功路径)", async () => {
53
+ const state = createEmptyStreamState("sid-2", "/tmp", "claude", 1);
54
+ await writeStreamState(state);
55
+
56
+ const entries = await readdir(STREAMS_DIR);
57
+ // 只应有 sid-2.json,没有 sid-2.json.tmp
58
+ expect(entries).toContain("sid-2.json");
59
+ expect(entries.some((e) => e.endsWith(".tmp"))).toBe(false);
60
+ });
61
+
62
+ it("覆盖写入时,reader 永远只读到完整 JSON(不读到半截)", async () => {
63
+ // 此测试验证:先写一个版本,再写第二个版本,reader 中间读取必然是某个完整版本
64
+ const state1 = createEmptyStreamState("sid-3", "/tmp", "claude", 1);
65
+ state1.accumulatedContent = "v1";
66
+ await writeStreamState(state1);
67
+
68
+ // 直接读原始字节,确认是完整 JSON
69
+ const raw1 = await readFile(join(STREAMS_DIR, "sid-3.json"), "utf-8");
70
+ expect(() => JSON.parse(raw1)).not.toThrow();
71
+ expect(JSON.parse(raw1).accumulatedContent).toBe("v1");
72
+
73
+ const state2 = createEmptyStreamState("sid-3", "/tmp", "claude", 2);
74
+ state2.accumulatedContent = "v2";
75
+ await writeStreamState(state2);
76
+
77
+ const raw2 = await readFile(join(STREAMS_DIR, "sid-3.json"), "utf-8");
78
+ expect(() => JSON.parse(raw2)).not.toThrow();
79
+ expect(JSON.parse(raw2).accumulatedContent).toBe("v2");
80
+ });
81
+
82
+ it("rename 失败时降级为直接覆盖写,不抛错 + 数据仍写入", async () => {
83
+ let renameCalls = 0;
84
+ _setRenameForTest(async () => {
85
+ renameCalls++;
86
+ throw Object.assign(new Error("EPERM mocked"), { code: "EPERM" });
87
+ });
88
+
89
+ try {
90
+ const state = createEmptyStreamState("sid-fallback", "/tmp", "claude", 1);
91
+ state.accumulatedContent = "fallback-content";
92
+
93
+ // 不应抛错
94
+ await expect(writeStreamState(state)).resolves.toBeUndefined();
95
+
96
+ // 数据仍应写入(降级路径走 writeFile 覆盖)
97
+ const got = await readStreamState("sid-fallback");
98
+ expect(got).not.toBeNull();
99
+ expect(got!.accumulatedContent).toBe("fallback-content");
100
+
101
+ expect(renameCalls).toBe(1);
102
+ } finally {
103
+ _resetRenameForTest();
104
+ }
105
+ });
106
+
107
+ it("readStreamState 对损坏的 JSON 返回 null(reader 兜底契约)", async () => {
108
+ // 直接写一段半截 JSON 到目标路径
109
+ const filePath = join(STREAMS_DIR, "sid-broken.json");
110
+ await writeFile(filePath, '{"sessionId":"sid-broken","accum', "utf-8");
111
+
112
+ const got = await readStreamState("sid-broken");
113
+ expect(got).toBeNull();
114
+ });
115
+
116
+ it("readStreamState 对不存在文件返回 null", async () => {
117
+ const got = await readStreamState("never-existed");
118
+ expect(got).toBeNull();
119
+ });
120
+ });
121
+
122
+ describe("createEmptyStreamState", () => {
123
+ it("生成的 state 字段正确且 status=running", () => {
124
+ const s = createEmptyStreamState("sid-X", "/cwd", "cursor", 5);
125
+ expect(s.sessionId).toBe("sid-X");
126
+ expect(s.cwd).toBe("/cwd");
127
+ expect(s.tool).toBe("cursor");
128
+ expect(s.turnCount).toBe(5);
129
+ expect(s.status).toBe("running");
130
+ expect(s.accumulatedContent).toBe("");
131
+ expect(s.finalReply).toBe("");
132
+ expect(s.chunkCount).toBe(0);
133
+ });
134
+ });
package/src/index.ts CHANGED
@@ -97,9 +97,11 @@ import {
97
97
  initClaudeSession,
98
98
  lastMsgTimestamps,
99
99
  processedMessages,
100
+ rebuildBindingsFromRegistry,
100
101
  resetState,
101
102
  resumeAndPrompt,
102
103
  sessionInfoMap,
104
+ switchChatBinding,
103
105
  recordSessionRegistry,
104
106
  getAdapterForTool,
105
107
  stopSession,
@@ -717,11 +719,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
717
719
  cwd = await getDefaultCwd(chatId);
718
720
  }
719
721
 
720
- // abort 旧 session,只解绑当前 chat
721
- // 旧 session 如果正在跑,display loop 继续服务其他群
722
- unbindChatFromSession(sessionId, chatId);
723
- displayCards.delete(chatId);
724
-
722
+ // 第一步:创建新 session(此时尚未碰任何内存绑定,失败可直接返回,
723
+ // 旧 session 状态完全保留)。
724
+ // 注意不 abort 旧 session:旧 session 若仍在跑,display loop 会继续
725
+ // 把卡片推到原群直到 prompt 自然结束(或被 /stop)
725
726
  let newSessionId: string;
726
727
  try {
727
728
  const init = await initClaudeSession(descriptionTool, cwd);
@@ -732,37 +733,46 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
732
733
  return;
733
734
  }
734
735
 
735
- // 绑定新 session
736
- bindChatToSession(newSessionId, chatId);
737
- recordLastActiveChat(newSessionId, chatId);
738
-
736
+ // 第二步:事务式切换 chat 绑定 —— 把 chat 从 oldSessionId 改嫁到
737
+ // newSessionId。switchChatBinding 内部保证:
738
+ // - 群聊先调 updateChatInfo 改群描述,失败则内存绑定完全不动
739
+ // - 私聊跳过 updateChatInfo(私聊 chatId 调群 API 必然失败)
740
+ // - API 成功后统一切换内存:unbind 旧 / clear displayCards / bind 新 /
741
+ // recordLastActiveChat / sessionInfoMap.set + 持久化 registry/sessions
742
+ // 详见 session.ts::switchChatBinding 注释。
739
743
  const descPrefix = sessionPrefixForTool(descriptionTool);
740
744
  const newName = sessionChatName("新会话", cwd);
741
- await updateChatInfo(freshToken, chatId, newName, `${descPrefix} ${newSessionId}`);
742
- console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
743
-
744
- sessionInfoMap.set(chatId, {
745
- sessionId: newSessionId,
746
- turnCount: 0,
747
- lastContextTokens: 0,
748
- startTime: Date.now(),
749
- tool: descriptionTool,
750
- });
751
- await recordSessionRegistry({
745
+ const switchResult = await switchChatBinding({
752
746
  chatId,
753
- sessionId: newSessionId,
747
+ chatType,
748
+ oldSessionId: sessionId,
749
+ newSessionId,
754
750
  tool: descriptionTool,
755
751
  chatName: newName,
756
- turnCount: 0,
757
- lastContextTokens: 0,
758
- startTime: Date.now(),
759
- running: false,
752
+ newDescription: `${descPrefix} ${newSessionId}`,
753
+ updateChatInfoFn: (cid, name, desc) => updateChatInfo(freshToken, cid, name, desc),
760
754
  });
761
- await saveSessionTool(newSessionId, descriptionTool, newName);
755
+ if (!switchResult.ok) {
756
+ // 飞书 API 失败:内存仍指向旧 session,新 session 已创建但无人使用,
757
+ // 留在 sessions.json(loadSessionTools)成为 orphan,可由 /sessions
758
+ // 列表展示供人工清理或下次 /newh 覆盖。代价小于让群 description
759
+ // 与内存绑定不一致。
760
+ logTrace(tid, "DONE", { outcome: "newh_update_chat_fail", error: switchResult.error?.message });
761
+ await sendCardReply(
762
+ freshToken, chatId, "Error",
763
+ `更新群描述失败,会话未切换(新 session 已创建但未启用):\n${switchResult.error?.message}`,
764
+ "red"
765
+ );
766
+ return;
767
+ }
768
+ if (chatType !== "p2p") {
769
+ console.log(`[${ts()}] [NEWH] Group updated: name="${newName}" desc="${descPrefix} ${newSessionId}"`);
770
+ }
762
771
 
763
772
  setChatAvatar(freshToken, chatId, descriptionTool, "new").catch(() => {});
764
773
 
765
- // 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到
774
+ // 如果新 session 有活跃 prompt,启动 display loop 让本群也能看到。
775
+ // /newh 后通常是空 session,这里几乎走不到,但留作防御。
766
776
  if (isSessionRunning(newSessionId)) {
767
777
  const { ensureDisplayLoop } = await import("./session.ts");
768
778
  ensureDisplayLoop(newSessionId);
@@ -834,12 +844,8 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
834
844
  }
835
845
  const target = ordered[index];
836
846
 
837
- // abort 当前 chat 的旧 session,只解绑再重新绑定
838
- if (sessionId) {
839
- unbindChatFromSession(sessionId, chatId);
840
- displayCards.delete(chatId);
841
- }
842
-
847
+ // 第一步:解析目标 session cwd(adapter.getSessionInfo 失败则 fallback,
848
+ // 这一步不动任何内存绑定,失败也不脏)
843
849
  const targetAdapter = getAdapterForTool(target.tool);
844
850
  let cwd2: string;
845
851
  try {
@@ -849,34 +855,43 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
849
855
  cwd2 = await getDefaultCwd(chatId);
850
856
  }
851
857
 
852
- // 绑定到新 session
853
- bindChatToSession(target.sessionId, chatId);
854
- recordLastActiveChat(target.sessionId, chatId);
855
-
858
+ // 第二步:事务式切换 chat 绑定到 target.sessionId。
859
+ // switchChatBinding 内部行为同 /newh,详见 session.ts::switchChatBinding。
860
+ // 注意:这里把 turnCount 沿用 target.turnCount(从 registry 读到的目标 chat
861
+ // 历史轮数),而不是从 0 开始,这样 /status 显示的轮数延续历史。
856
862
  const descPrefix2 = sessionPrefixForTool(target.tool);
857
863
  const newName2 = target.chatName || sessionChatName("新会话", cwd2);
858
- await updateChatInfo(freshToken, chatId, newName2, `${descPrefix2} ${target.sessionId}`);
859
- console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
860
-
861
- sessionInfoMap.set(chatId, {
862
- sessionId: target.sessionId,
863
- turnCount: target.turnCount,
864
- lastContextTokens: 0,
865
- startTime: Date.now(),
866
- tool: target.tool,
867
- });
868
- await recordSessionRegistry({
864
+ const switchResult = await switchChatBinding({
869
865
  chatId,
870
- sessionId: target.sessionId,
866
+ chatType,
867
+ oldSessionId: sessionId,
868
+ newSessionId: target.sessionId,
871
869
  tool: target.tool,
872
870
  chatName: newName2,
873
- running: false,
871
+ newDescription: `${descPrefix2} ${target.sessionId}`,
872
+ initialTurnCount: target.turnCount,
873
+ initialContextTokens: 0,
874
+ updateChatInfoFn: (cid, name, desc) => updateChatInfo(freshToken, cid, name, desc),
874
875
  });
875
- await saveSessionTool(target.sessionId, target.tool, newName2);
876
+ if (!switchResult.ok) {
877
+ logTrace(tid, "DONE", { outcome: "session_update_chat_fail", error: switchResult.error?.message });
878
+ await sendCardReply(
879
+ freshToken, chatId, "Error",
880
+ `更新群描述失败,会话未切换:\n${switchResult.error?.message}`,
881
+ "red"
882
+ );
883
+ return;
884
+ }
885
+ if (chatType !== "p2p") {
886
+ console.log(`[${ts()}] [SESSION] Switched to session ${target.sessionId} (#${index + 1}), name="${newName2}"`);
887
+ }
876
888
 
877
889
  setChatAvatar(freshToken, chatId, target.tool, "new").catch(() => {});
878
890
 
879
- // 如果新 session 有活跃 prompt,加上 display loop
891
+ // 如果新 session 有活跃 prompt,加上 display loop。注意:此时
892
+ // recordLastActiveChat 已经把 lastActive 改成当前 chat,会"抢走"
893
+ // 原有群的 display 推送(见 review 中 corner case 5)。这是已知
894
+ // 设计权衡:用户主动切换就是想"接管"该 session 的显示。
880
895
  if (isSessionRunning(target.sessionId)) {
881
896
  const { ensureDisplayLoop } = await import("./session.ts");
882
897
  ensureDisplayLoop(target.sessionId);
@@ -1231,25 +1246,34 @@ async function startBotServiceCore(): Promise<void> {
1231
1246
  console.error(`[WS] Local relay error: ${err.message}`);
1232
1247
  });
1233
1248
  } else {
1249
+ // 进程首次启动:此时所有 Map 都是空的,resetState 主要是打个 LOG 标识"开始
1250
+ // 干净状态"。修正残留的 running stream-state 并重建 session→chat 映射。
1234
1251
  resetState();
1235
- // 启动时修正残留的 running 状态并重建 session→chat 映射
1236
1252
  fixStaleStreamStates().then(async () => {
1237
1253
  const registry = await loadSessionRegistryForBinding();
1238
1254
  rebuildSessionChatsFromRegistry(registry);
1239
1255
  }).catch((err) => console.error(`[${ts()}] Init bindings failed: ${(err as Error).message}`));
1240
1256
 
1257
+ // ⚠️ 关键设计:onReady / onReconnected 只重建只读映射,**绝不**清空运行时
1258
+ // 状态(activePrompts、displayLoops、sessionInfoMap、processedMessages)。
1259
+ // SDK 重连只是底层 WebSocket 抖动,业务层不受影响:
1260
+ // - 后台 generator 仍在跑、stream-state 仍在被写
1261
+ // - display loop 仍在向群推送
1262
+ // - 已处理消息的去重 set 必须保留,避免 SDK 重推老消息时 prompt 跑两遍
1263
+ // 历史 bug:此处曾误调 resetState() 导致重连即让所有后台任务变孤儿,
1264
+ // 同一 session 还可能双开 prompt(详见 session.ts::resetState 注释)。
1241
1265
  const wsClient = new WSClient({
1242
1266
  appId: APP_ID,
1243
1267
  appSecret: APP_SECRET,
1244
1268
  onReady: async () => {
1245
- resetState();
1246
- const registry = await loadSessionRegistryForBinding();
1247
- rebuildSessionChatsFromRegistry(registry);
1269
+ await rebuildBindingsFromRegistry().catch((err) =>
1270
+ console.error(`[${ts()}] [SDK READY] rebuild bindings failed: ${(err as Error).message}`)
1271
+ );
1248
1272
  },
1249
1273
  onReconnected: async () => {
1250
- resetState();
1251
- const registry = await loadSessionRegistryForBinding();
1252
- rebuildSessionChatsFromRegistry(registry);
1274
+ await rebuildBindingsFromRegistry().catch((err) =>
1275
+ console.error(`[${ts()}] [SDK RECONNECT] rebuild bindings failed: ${(err as Error).message}`)
1276
+ );
1253
1277
  },
1254
1278
  });
1255
1279
 
package/src/session.ts CHANGED
@@ -85,6 +85,29 @@ export const sessionInfoMap = new Map<string, {
85
85
  tool: string;
86
86
  }>();
87
87
 
88
+ /**
89
+ * 清空所有进程内运行时状态。
90
+ *
91
+ * ⚠️ 红线:**绝对不要**在飞书 SDK 的 onReady / onReconnected 回调里调用本函数。
92
+ * SDK 的 WebSocket 重连只是底层连接抖动,业务层(活跃 prompt、display loop、
93
+ * stream-state 文件、轮数计数)完全不受影响。在重连里调 resetState 会:
94
+ * 1) `activePrompts.clear()` 只是删 Map,**不会** abort 后台 generator。
95
+ * generator 继续跑、继续写 stream-state.json,但 display loop 已被
96
+ * stop,用户群里再也看不到任何更新;最终回复永远不发到群。
97
+ * 2) 该 sessionId 在内存里"看似空闲",下一条用户消息进来会**第二次进入**
98
+ * `runAgentSession`,同一个 cursor/claude session 同时跑两条 prompt,
99
+ * 输出互相串扰、token 计费翻倍。
100
+ * 3) `processedMessages` / `lastMsgTimestamps` 被清,SDK 重连后若服务端
101
+ * 重推已 ack 的消息,去重失效会让同一 prompt 被处理两次。
102
+ * 4) `sessionInfoMap` 清空后,群再发消息时 nextTurnCount 从 1 重新计数。
103
+ *
104
+ * 合法调用点:
105
+ * - 单元测试 setup(清测试间状态)
106
+ * - 进程首次启动(此时 Map 都是空的,调用纯粹是为了打 LOG)
107
+ *
108
+ * SDK 重连场景请改用 `rebuildBindingsFromRegistry()`,它只重建 sessionId →
109
+ * chatId 映射,不动任何运行时状态。
110
+ */
88
111
  export function resetState(): void {
89
112
  for (const entry of chatSessionMap.values()) {
90
113
  if (entry.spinnerTimer) clearInterval(entry.spinnerTimer);
@@ -94,7 +117,6 @@ export function resetState(): void {
94
117
  sessionInfoMap.clear();
95
118
  processedMessages.clear();
96
119
  lastMsgTimestamps.clear();
97
- // 清理新的全局状态
98
120
  activePrompts.clear();
99
121
  displayCards.clear();
100
122
  for (const stop of displayLoops.values()) stop();
@@ -102,6 +124,9 @@ export function resetState(): void {
102
124
  console.log(`[${ts()}] [RESET] State cleared (dedup + active sessions + bindings)`);
103
125
  }
104
126
 
127
+ // 注:`rebuildBindingsFromRegistry` 定义在下方与 loadSessionRegistry 同区域,
128
+ // 是 onReady/onReconnected 取代 resetState 的正确入口。
129
+
105
130
  // ---------------------------------------------------------------------------
106
131
  // Adapter: 按 tool 创建并缓存
107
132
  // ---------------------------------------------------------------------------
@@ -235,6 +260,26 @@ export async function loadSessionRegistryForBinding(): Promise<SessionRegistryDa
235
260
  return loadSessionRegistry();
236
261
  }
237
262
 
263
+ /**
264
+ * 从持久化的 registry 重建 sessionId → chatId 映射。
265
+ *
266
+ * 设计契约(替代之前 onReady/onReconnected 误用的 resetState):
267
+ * - **不动** activePrompts:后台 prompt 在 SDK 重连后必须继续被识别为活跃,
268
+ * 否则下条用户消息会绕过 isSessionRunning 检查再开一条 prompt,
269
+ * 导致同一 sessionId 双开 generator
270
+ * - **不动** sessionInfoMap:内存里的轮数/contextTokens 比 registry 更新
271
+ * - **不动** displayCards / displayLoops:正在跑的 prompt 还需要它们继续推卡片
272
+ * - **不动** processedMessages / lastMsgTimestamps:SDK 重连若重推已 ack 消息,
273
+ * 去重 set 还在才能避免同一 prompt 跑两遍
274
+ *
275
+ * 唯一被重建的是 sessionChatsMap(通过调用 rebuildSessionChatsFromRegistry)——
276
+ * 该 Map 是从 registry 派生的纯只读映射,重建是幂等且廉价的。
277
+ */
278
+ export async function rebuildBindingsFromRegistry(): Promise<void> {
279
+ const registry = await loadSessionRegistry();
280
+ rebuildSessionChatsFromRegistry(registry);
281
+ }
282
+
238
283
  async function saveSessionRegistry(data: SessionRegistryData): Promise<void> {
239
284
  try {
240
285
  await mkdir(dirname(sessionRegistryFile), { recursive: true });
@@ -379,6 +424,118 @@ export function accumulateBlockContent(
379
424
  }
380
425
  }
381
426
 
427
+ // ---------------------------------------------------------------------------
428
+ // switchChatBinding — /newh、/session N 共用的事务式"切换 chat 绑定"
429
+ // ---------------------------------------------------------------------------
430
+ //
431
+ // 设计契约(解决三类历史 bug):
432
+ //
433
+ // 1. 私聊不能调 updateChatInfo(飞书 API 在 p2p chatId 上会返回非 0 → throw)。
434
+ // 之前的实现没判断 chatType,私聊 /newh、/session N 走到 updateChatInfo
435
+ // 就直接抛错,留下"内存已切换、registry 没更新"的脏状态。
436
+ //
437
+ // 2. updateChatInfo 群聊也可能因为网络/频控失败。之前的代码顺序是
438
+ // 先 unbind 旧 → bind 新 → 再调 updateChatInfo
439
+ // API 失败后内存绑定已经切走,但群 description 还是旧 sessionId。
440
+ // 下次用户在群里发消息时,extractSessionInfo 拿到旧 sessionId,而内存绑定
441
+ // 指向新 sessionId,路由完全错乱(参考 /newh 的 corner case 7)。
442
+ //
443
+ // 3. 改成"先 API 后内存"的顺序后,API 失败就完全不切换内存,下次消息按
444
+ // 旧 description 正常路由到旧 session,新创建的 session 留在 sessions.json
445
+ // 里成为可清理的 orphan。
446
+ //
447
+ // 调用方约定:
448
+ // - newSessionId 必须是已经 createSession 完成的真实 session(本函数不创建)
449
+ // - oldSessionId 为 null 表示当前 chat 没有任何旧绑定(比如私聊首次绑)
450
+ // - 私聊跳过 updateChatInfo,直接做内存切换 + registry 持久化
451
+ // - API 失败时:
452
+ // * 不动内存绑定 / sessionInfoMap / displayCards
453
+ // * 返回 { ok: false, error }
454
+ // * 调用方负责把错误反馈给用户
455
+ // ---------------------------------------------------------------------------
456
+
457
+ export interface SwitchChatBindingArgs {
458
+ chatId: string;
459
+ chatType: string;
460
+ oldSessionId: string | null;
461
+ newSessionId: string;
462
+ tool: string;
463
+ /** 群名(私聊忽略) */
464
+ chatName: string;
465
+ /** 群描述(私聊忽略),通常为 `${sessionPrefixForTool(tool)} ${newSessionId}` */
466
+ newDescription: string;
467
+ /** 切换后 sessionInfoMap 的初始 turnCount/lastContextTokens(如沿用历史) */
468
+ initialTurnCount?: number;
469
+ initialContextTokens?: number;
470
+ /** 飞书 updateChatInfo 实现,依赖注入便于测试 mock */
471
+ updateChatInfoFn: (chatId: string, name: string, description: string) => Promise<void>;
472
+ }
473
+
474
+ export interface SwitchChatBindingResult {
475
+ ok: boolean;
476
+ error?: Error;
477
+ }
478
+
479
+ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<SwitchChatBindingResult> {
480
+ const {
481
+ chatId,
482
+ chatType,
483
+ oldSessionId,
484
+ newSessionId,
485
+ tool,
486
+ chatName,
487
+ newDescription,
488
+ initialTurnCount = 0,
489
+ initialContextTokens = 0,
490
+ updateChatInfoFn,
491
+ } = args;
492
+
493
+ // Step 1: 群聊场景先调用飞书 API(不可逆操作放最前)。
494
+ // 私聊跳过——p2p chatId 调 updateChatInfo 必然失败。
495
+ if (chatType !== "p2p") {
496
+ try {
497
+ await updateChatInfoFn(chatId, chatName, newDescription);
498
+ } catch (err) {
499
+ // API 失败:完全不动内存,调用方负责回报用户。
500
+ return { ok: false, error: err as Error };
501
+ }
502
+ }
503
+
504
+ // Step 2: API 成功(或私聊跳过)后,原子地切换内存绑定。
505
+ // 这一段全是同步 Map 操作,不会失败。
506
+ if (oldSessionId) {
507
+ unbindChatFromSession(oldSessionId, chatId);
508
+ displayCards.delete(chatId);
509
+ }
510
+ bindChatToSession(newSessionId, chatId);
511
+ recordLastActiveChat(newSessionId, chatId);
512
+
513
+ const now = Date.now();
514
+ sessionInfoMap.set(chatId, {
515
+ sessionId: newSessionId,
516
+ turnCount: initialTurnCount,
517
+ lastContextTokens: initialContextTokens,
518
+ startTime: now,
519
+ tool,
520
+ });
521
+
522
+ // Step 3: 持久化(registry + sessions.json)。
523
+ // 这两步即使失败也不影响内存正确性,下次 prompt 会再写一次。
524
+ await recordSessionRegistry({
525
+ chatId,
526
+ sessionId: newSessionId,
527
+ tool,
528
+ chatName,
529
+ turnCount: initialTurnCount,
530
+ lastContextTokens: initialContextTokens,
531
+ startTime: now,
532
+ running: false,
533
+ });
534
+ await saveSessionTool(newSessionId, tool, chatName);
535
+
536
+ return { ok: true };
537
+ }
538
+
382
539
  // ---------------------------------------------------------------------------
383
540
  // AI tool session management
384
541
  // ---------------------------------------------------------------------------
@@ -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
- export async function writeStreamState(state: StreamState): Promise<void> {
41
- const filePath = getStreamStatePath(state.sessionId);
42
- try {
43
- await mkdir(dirname(filePath), { recursive: true });
44
- // 先写临时文件再 rename,保证原子性
45
- const tmpPath = filePath + ".tmp";
46
- await writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
47
- await writeFile(filePath, JSON.stringify(state, null, 2), "utf-8");
48
- } catch (err) {
49
- console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
50
- }
51
- }
52
-
53
- export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
54
- return {
55
- sessionId,
56
- status: "running",
57
- accumulatedContent: "",
58
- finalReply: "",
59
- chunkCount: 0,
60
- turnCount,
61
- contextTokens: 0,
62
- updatedAt: Date.now(),
63
- cwd,
64
- tool,
65
- };
66
- }
67
-
68
- /** 进程重启时修正虚假的 "running" 状态 */
69
- export async function fixStaleStreamStates(): Promise<void> {
70
- // 启动时将残留的 running 标记为 error
71
- // 实际 agent 进程已不存在,下次 prompt 时会自然覆盖
72
- try {
73
- const { readdir } = await import("node:fs/promises");
74
- let entries: string[];
75
- try {
76
- entries = await readdir(STREAMS_DIR);
77
- } catch {
78
- return;
79
- }
80
- for (const entry of entries) {
81
- if (!entry.endsWith(".json")) continue;
82
- const state = await readStreamState(entry.replace(".json", ""));
83
- if (state && state.status === "running") {
84
- state.status = "error";
85
- state.accumulatedContent += "\n\n⚠️ 进程重启,流式输出中断。";
86
- state.updatedAt = Date.now();
87
- await writeStreamState(state);
88
- console.log(`[${ts()}] [STREAM-STATE] marked stale running as error: ${state.sessionId}`);
89
- }
90
- }
91
- } catch (err) {
92
- console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
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
  }