chatccc 0.2.209 → 0.2.210

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.209",
3
+ "version": "0.2.210",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -670,6 +670,7 @@ describe("runAgentSession process monitor", () => {
670
670
 
671
671
  describe("runAgentSession response stall watchdog", () => {
672
672
  let tempDir = "";
673
+ const recoveryPrompt = "完成了吗?如果没完成继续";
673
674
 
674
675
  beforeEach(async () => {
675
676
  vi.useFakeTimers();
@@ -766,6 +767,227 @@ describe("runAgentSession response stall watchdog", () => {
766
767
  autoEndedAt: expect.any(Number),
767
768
  });
768
769
  });
770
+
771
+ it("runs the reserved recovery prompt before an already queued user message", async () => {
772
+ vi.setSystemTime(0);
773
+ _setResponseStallTimeoutForTest(100);
774
+ _setResponseStallCheckIntervalForTest(10);
775
+ _setProcessAliveForTest(() => true);
776
+
777
+ const platform = mockPlatform("feishu");
778
+ setSessionPlatform(platform);
779
+ bindChatToSession("sid-recovery-priority", "chat-recovery-priority");
780
+ recordLastActiveChat("sid-recovery-priority", "chat-recovery-priority");
781
+
782
+ const receivedPrompts: string[] = [];
783
+ const adapter: ToolAdapter = {
784
+ displayName: "Any Agent",
785
+ sessionDescPrefix: "Agent Session:",
786
+ createSession: async () => ({ sessionId: "sid-recovery-priority" }),
787
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
788
+ closeSession: async () => {},
789
+ prompt: async function* (
790
+ _sid: string,
791
+ text: string,
792
+ _cwd: string,
793
+ signal?: AbortSignal,
794
+ options?: ToolPromptOptions,
795
+ ) {
796
+ receivedPrompts.push(text);
797
+ options?.onProcessStart?.({ pid: 5252 + receivedPrompts.length });
798
+ if (receivedPrompts.length === 1) {
799
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
800
+ await new Promise<void>((resolve) => {
801
+ if (signal?.aborted) {
802
+ resolve();
803
+ return;
804
+ }
805
+ signal?.addEventListener("abort", () => resolve(), { once: true });
806
+ });
807
+ return;
808
+ }
809
+ yield { type: "assistant", blocks: [{ type: "text", text: "recovery completed" }] };
810
+ },
811
+ };
812
+ _setAdapterForToolForTest("claude", adapter);
813
+
814
+ enqueueMessage("sid-recovery-priority", {
815
+ text: "queued user prompt",
816
+ chatId: "chat-recovery-priority",
817
+ openId: "open-user",
818
+ msgTimestamp: 1,
819
+ chatType: "p2p",
820
+ });
821
+ const consumeQueued = vi.fn();
822
+ setQueueConsumer(consumeQueued);
823
+
824
+ const firstRun = runAgentSession(
825
+ "sid-recovery-priority",
826
+ "first prompt",
827
+ platform,
828
+ "chat-recovery-priority",
829
+ 0,
830
+ "claude",
831
+ );
832
+
833
+ await vi.waitFor(() => {
834
+ expect(activePrompts.get("sid-recovery-priority")?.responseProgress).toBeDefined();
835
+ });
836
+ const firstProgress = activePrompts.get("sid-recovery-priority")!.responseProgress!;
837
+ await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
838
+ await firstRun;
839
+
840
+ expect(receivedPrompts).toHaveLength(1);
841
+ expect(consumeQueued).not.toHaveBeenCalled();
842
+ expect(isSessionRunning("sid-recovery-priority")).toBe(true);
843
+ expect(platform.sendText).toHaveBeenCalledWith(
844
+ "chat-recovery-priority",
845
+ "检测到会话停滞,正在自动确认并继续。",
846
+ );
847
+
848
+ await vi.advanceTimersByTimeAsync(200);
849
+ await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
850
+ expect(receivedPrompts[1]).toContain(recoveryPrompt);
851
+ expect(receivedPrompts[1]).not.toContain("queued user prompt");
852
+ expect(consumeQueued).not.toHaveBeenCalled();
853
+
854
+ await vi.advanceTimersByTimeAsync(200);
855
+ expect(consumeQueued).toHaveBeenCalledTimes(1);
856
+ expect(consumeQueued).toHaveBeenCalledWith(
857
+ platform,
858
+ expect.objectContaining({ text: "queued user prompt" }),
859
+ );
860
+ setQueueConsumer(() => {});
861
+ });
862
+
863
+ it("does not auto-recover a second consecutive response stall", async () => {
864
+ vi.setSystemTime(0);
865
+ _setResponseStallTimeoutForTest(100);
866
+ _setResponseStallCheckIntervalForTest(10);
867
+ _setProcessAliveForTest(() => true);
868
+
869
+ const platform = mockPlatform("feishu");
870
+ setSessionPlatform(platform);
871
+ bindChatToSession("sid-recovery-limit", "chat-recovery-limit");
872
+ recordLastActiveChat("sid-recovery-limit", "chat-recovery-limit");
873
+
874
+ const receivedPrompts: string[] = [];
875
+ const adapter: ToolAdapter = {
876
+ displayName: "Any Agent",
877
+ sessionDescPrefix: "Agent Session:",
878
+ createSession: async () => ({ sessionId: "sid-recovery-limit" }),
879
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
880
+ closeSession: async () => {},
881
+ prompt: async function* (
882
+ _sid: string,
883
+ text: string,
884
+ _cwd: string,
885
+ signal?: AbortSignal,
886
+ options?: ToolPromptOptions,
887
+ ) {
888
+ receivedPrompts.push(text);
889
+ options?.onProcessStart?.({ pid: 6262 + receivedPrompts.length });
890
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
891
+ await new Promise<void>((resolve) => {
892
+ if (signal?.aborted) {
893
+ resolve();
894
+ return;
895
+ }
896
+ signal?.addEventListener("abort", () => resolve(), { once: true });
897
+ });
898
+ },
899
+ };
900
+ _setAdapterForToolForTest("claude", adapter);
901
+
902
+ const firstRun = runAgentSession(
903
+ "sid-recovery-limit",
904
+ "first prompt",
905
+ platform,
906
+ "chat-recovery-limit",
907
+ 0,
908
+ "claude",
909
+ );
910
+ await vi.waitFor(() => {
911
+ expect(activePrompts.get("sid-recovery-limit")?.responseProgress).toBeDefined();
912
+ });
913
+ const firstProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
914
+ await vi.advanceTimersByTimeAsync(firstProgress.unchangedSince + 101 - Date.now());
915
+ await firstRun;
916
+
917
+ await vi.advanceTimersByTimeAsync(200);
918
+ await vi.waitFor(() => expect(receivedPrompts).toHaveLength(2));
919
+ const recoveryProgress = activePrompts.get("sid-recovery-limit")!.responseProgress!;
920
+ await vi.advanceTimersByTimeAsync(recoveryProgress.unchangedSince + 101 - Date.now());
921
+ await vi.waitFor(() => expect(isSessionRunning("sid-recovery-limit")).toBe(false));
922
+
923
+ await vi.advanceTimersByTimeAsync(1_000);
924
+ expect(receivedPrompts).toHaveLength(2);
925
+ expect(receivedPrompts[1]).toContain(recoveryPrompt);
926
+ expect(platform.sendText).toHaveBeenCalledWith(
927
+ "chat-recovery-limit",
928
+ "⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。",
929
+ );
930
+ });
931
+
932
+ it("lets /stop cancel a reserved recovery before it starts", async () => {
933
+ vi.setSystemTime(0);
934
+ _setResponseStallTimeoutForTest(100);
935
+ _setResponseStallCheckIntervalForTest(10);
936
+ _setProcessAliveForTest(() => true);
937
+
938
+ const platform = mockPlatform("feishu");
939
+ setSessionPlatform(platform);
940
+ bindChatToSession("sid-recovery-stop", "chat-recovery-stop");
941
+ recordLastActiveChat("sid-recovery-stop", "chat-recovery-stop");
942
+
943
+ const receivedPrompts: string[] = [];
944
+ const adapter: ToolAdapter = {
945
+ displayName: "Any Agent",
946
+ sessionDescPrefix: "Agent Session:",
947
+ createSession: async () => ({ sessionId: "sid-recovery-stop" }),
948
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
949
+ closeSession: async () => {},
950
+ prompt: async function* (
951
+ _sid: string,
952
+ text: string,
953
+ _cwd: string,
954
+ signal?: AbortSignal,
955
+ ) {
956
+ receivedPrompts.push(text);
957
+ yield { type: "assistant", blocks: [{ type: "text", text: "" }] };
958
+ await new Promise<void>((resolve) => {
959
+ if (signal?.aborted) {
960
+ resolve();
961
+ return;
962
+ }
963
+ signal?.addEventListener("abort", () => resolve(), { once: true });
964
+ });
965
+ },
966
+ };
967
+ _setAdapterForToolForTest("claude", adapter);
968
+
969
+ const firstRun = runAgentSession(
970
+ "sid-recovery-stop",
971
+ "first prompt",
972
+ platform,
973
+ "chat-recovery-stop",
974
+ 0,
975
+ "claude",
976
+ );
977
+ await vi.waitFor(() => {
978
+ expect(activePrompts.get("sid-recovery-stop")?.responseProgress).toBeDefined();
979
+ });
980
+ const progress = activePrompts.get("sid-recovery-stop")!.responseProgress!;
981
+ await vi.advanceTimersByTimeAsync(progress.unchangedSince + 101 - Date.now());
982
+ await firstRun;
983
+
984
+ expect(isSessionRunning("sid-recovery-stop")).toBe(true);
985
+ expect(stopSession("sid-recovery-stop")).toBe(true);
986
+ await vi.advanceTimersByTimeAsync(500);
987
+
988
+ expect(receivedPrompts).toHaveLength(1);
989
+ expect(isSessionRunning("sid-recovery-stop")).toBe(false);
990
+ });
769
991
  });
770
992
 
771
993
  describe("unified display loop WeChat delta", () => {
@@ -57,9 +57,17 @@ export function hasChatsForSession(sessionId: string): boolean {
57
57
  // 也必须阻止下一轮提前进入,否则旧会话的落盘可能覆盖新会话绑定。
58
58
  const finalizingSessions = new Set<string>();
59
59
 
60
- /** 检查 sessionId 是否有活跃 prompt 或正在完成本轮异步收尾。 */
60
+ // response-stall 自动恢复预约只存在于当前进程内:用户已明确 ChatCCC 重启后
61
+ // 不需要补发恢复 prompt。预约从检测到第一次停滞起一直持有到恢复轮同步进入
62
+ // runAgentSession,填补旧轮收尾和新轮 activePrompts.set 之间的空窗,保证此时
63
+ // 到达的普通用户消息只能进入缓存队列,不能抢在恢复 prompt 前执行。
64
+ const autoRecoveryReservations = new Set<string>();
65
+
66
+ /** 检查 sessionId 是否有活跃 prompt、正在收尾或已预约自动恢复。 */
61
67
  export function isSessionRunning(sessionId: string): boolean {
62
- return activePrompts.has(sessionId) || finalizingSessions.has(sessionId);
68
+ return activePrompts.has(sessionId)
69
+ || finalizingSessions.has(sessionId)
70
+ || autoRecoveryReservations.has(sessionId);
63
71
  }
64
72
 
65
73
  export function markSessionFinalizing(sessionId: string): void {
@@ -69,6 +77,27 @@ export function markSessionFinalizing(sessionId: string): void {
69
77
  export function clearSessionFinalizing(sessionId: string): void {
70
78
  finalizingSessions.delete(sessionId);
71
79
  }
80
+
81
+ /** 为 response-stall 后的内部恢复轮预留下一次执行权。 */
82
+ export function reserveAutoRecovery(sessionId: string): boolean {
83
+ if (autoRecoveryReservations.has(sessionId)) return false;
84
+ autoRecoveryReservations.add(sessionId);
85
+ return true;
86
+ }
87
+
88
+ /** 恢复轮启动前原子消费预约;返回 false 表示已被 /stop 取消。 */
89
+ export function consumeAutoRecoveryReservation(sessionId: string): boolean {
90
+ return autoRecoveryReservations.delete(sessionId);
91
+ }
92
+
93
+ /** 取消尚未启动的恢复轮。 */
94
+ export function cancelAutoRecoveryReservation(sessionId: string): boolean {
95
+ return autoRecoveryReservations.delete(sessionId);
96
+ }
97
+
98
+ export function hasAutoRecoveryReservation(sessionId: string): boolean {
99
+ return autoRecoveryReservations.has(sessionId);
100
+ }
72
101
 
73
102
  // ---------------------------------------------------------------------------
74
103
  // activePrompts: sessionId → 活跃 prompt 控制
@@ -92,6 +121,8 @@ export interface ActivePrompt {
92
121
  abnormalExitNotified?: boolean;
93
122
  /** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
94
123
  resourceStuck?: boolean;
124
+ /** True only for the single internal continuation turn after a response stall. */
125
+ autoRecovery?: boolean;
95
126
  /** Adapter-provided callback to close the underlying SDK session / subprocess.
96
127
  * Called by stop-stuck-loop before controller.abort() to terminate the CLI
97
128
  * process immediately, rather than waiting for the async generator to unblock. */
@@ -245,6 +276,7 @@ export function resetBindingState(): void {
245
276
  }
246
277
  activePrompts.clear();
247
278
  finalizingSessions.clear();
279
+ autoRecoveryReservations.clear();
248
280
  queuedMessages.clear();
249
281
  displayCards.clear();
250
282
  if (unifiedDisplayLoopHandle !== null) {
package/src/session.ts CHANGED
@@ -77,6 +77,10 @@ import {
77
77
  consumeQueuePreservedChat,
78
78
  markSessionFinalizing,
79
79
  clearSessionFinalizing,
80
+ reserveAutoRecovery,
81
+ consumeAutoRecoveryReservation,
82
+ cancelAutoRecoveryReservation,
83
+ hasAutoRecoveryReservation,
80
84
  } from "./session-chat-binding.ts";
81
85
 
82
86
  async function sendFinalReplyTextOnce(
@@ -161,6 +165,11 @@ function platformForChat(chatId: string): PlatformAdapter | null {
161
165
  const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
162
166
  const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
163
167
  const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
168
+ export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
169
+ export const RESPONSE_STALL_RECOVERY_NOTICE = "检测到会话停滞,正在自动确认并继续。";
170
+ export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
171
+ "⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。";
172
+ const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
164
173
  let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
165
174
  let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
166
175
  let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
@@ -966,19 +975,28 @@ export async function resumeAndPrompt(
966
975
  return runAgentSession(sessionId, userText, platform, chatId, msgTimestamp, tool, traceId);
967
976
  }
968
977
 
969
- // ---------------------------------------------------------------------------
970
- // runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
971
- // ---------------------------------------------------------------------------
972
-
973
- export async function runAgentSession(
974
- sessionId: string,
975
- userText: string,
976
- platform: PlatformAdapter,
977
- _chatId: string,
978
- msgTimestamp: number,
979
- tool: string,
980
- traceId?: string,
981
- ): Promise<void> {
978
+ // ---------------------------------------------------------------------------
979
+ // runAgentSession — session 中心的 agent prompt(文件持久化 + display 解耦)
980
+ // ---------------------------------------------------------------------------
981
+
982
+ interface RunAgentSessionOptions {
983
+ /**
984
+ * 标记本轮是 response-stall 后的唯一一次内部续跑。若本轮再次因相同原因
985
+ * 停滞,不再递归创建第三轮,避免服务异常期间无限消耗 token。
986
+ */
987
+ autoRecovery?: boolean;
988
+ }
989
+
990
+ export async function runAgentSession(
991
+ sessionId: string,
992
+ userText: string,
993
+ platform: PlatformAdapter,
994
+ _chatId: string,
995
+ msgTimestamp: number,
996
+ tool: string,
997
+ traceId?: string,
998
+ options: RunAgentSessionOptions = {},
999
+ ): Promise<void> {
982
1000
  const tid = traceId ?? "";
983
1001
 
984
1002
  // 记录用户最后发送消息的群(display loop 只推送到该群)
@@ -1002,11 +1020,12 @@ export async function runAgentSession(
1002
1020
  // 注意:下面的 try/catch 在准备失败时会清理 activePrompts。
1003
1021
  const controller = new AbortController();
1004
1022
  const now = Date.now();
1005
- activePrompts.set(sessionId, {
1006
- controller,
1007
- stopped: false,
1008
- startTime: now,
1009
- });
1023
+ activePrompts.set(sessionId, {
1024
+ controller,
1025
+ stopped: false,
1026
+ startTime: now,
1027
+ autoRecovery: options.autoRecovery === true,
1028
+ });
1010
1029
 
1011
1030
  // 资源监控僵死检测:CPU + 内存连续 3 分钟无变化 → 强制停止
1012
1031
  const onResourceStuck = (data: { pid: number; sessionId: string; idleMinutes: number }) => {
@@ -1257,6 +1276,13 @@ export async function runAgentSession(
1257
1276
  }
1258
1277
 
1259
1278
  const autoEndedAt = Date.now();
1279
+ // 普通轮第一次因回复停滞结束时,立即预约同 session 的内部续跑。
1280
+ // 预约先于 abort/收尾建立,isSessionRunning 会在整个交接窗口保持 true,
1281
+ // 因此恰好到达的用户消息只能排队,绝不可能抢在恢复 prompt 前。
1282
+ // 若当前已经是恢复轮,则不再预约第三轮。
1283
+ if (!current.autoRecovery) {
1284
+ reserveAutoRecovery(sessionId);
1285
+ }
1260
1286
  current.autoEnded = true;
1261
1287
  current.autoEndedAt = autoEndedAt;
1262
1288
  clearPromptResponseStallMonitor(sessionId);
@@ -1376,6 +1402,7 @@ export async function runAgentSession(
1376
1402
  const wasAbnormalExit = prompt?.abnormalExit ?? false;
1377
1403
  const wasResourceStuck = prompt?.resourceStuck ?? false;
1378
1404
  const wasAutoEnded = prompt?.autoEnded ?? false;
1405
+ const wasAutoRecovery = prompt?.autoRecovery ?? false;
1379
1406
  const autoEndedAt = prompt?.autoEndedAt;
1380
1407
  clearPromptResponseStallMonitor(sessionId);
1381
1408
  clearPromptProcessMonitor(sessionId);
@@ -1426,10 +1453,12 @@ export async function runAgentSession(
1426
1453
  ...(preserveStuckAt ? { stuckAt: preserveStuckAt } : {}),
1427
1454
  ...(autoEndedAt !== undefined ? { autoEndedAt } : {}),
1428
1455
  });
1429
-
1456
+
1430
1457
  // display loop 下一轮会读到最终状态并发送消息
1431
-
1432
- if (wasStopped) {
1458
+
1459
+ let autoRecoveryTarget: { chatId: string; platform: PlatformAdapter } | undefined;
1460
+
1461
+ if (wasStopped) {
1433
1462
  for (const cid of getChatsForSession(sessionId)) {
1434
1463
  const finfo = sessionInfoMap.get(cid);
1435
1464
  await recordSessionRegistry({
@@ -1464,9 +1493,9 @@ export async function runAgentSession(
1464
1493
  }
1465
1494
  const activeAutoEnded = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1466
1495
  if (activeAutoEnded) {
1496
+ const pp = platformForChat(activeAutoEnded) ?? platform;
1467
1497
  const terminalState = await readStreamState(sessionId);
1468
1498
  if (!displayCards.has(activeAutoEnded) && (!terminalState || !isFinalReplySentForTurn(terminalState))) {
1469
- const pp = platformForChat(activeAutoEnded) ?? platform;
1470
1499
  await sendFinalReplyTextOnce(
1471
1500
  pp,
1472
1501
  activeAutoEnded,
@@ -1475,7 +1504,24 @@ export async function runAgentSession(
1475
1504
  formatAutoEndedReply(finalReplyToWrite),
1476
1505
  );
1477
1506
  }
1478
- platform.setChatAvatar(activeAutoEnded, tool, "idle").catch(() => {});
1507
+ pp.setChatAvatar(activeAutoEnded, tool, "idle").catch(() => {});
1508
+
1509
+ if (wasAutoRecovery) {
1510
+ // 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
1511
+ // 自动链,避免第三轮及之后的无限续跑。
1512
+ await pp.sendText(
1513
+ activeAutoEnded,
1514
+ RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
1515
+ ).catch(() => {});
1516
+ } else if (hasAutoRecoveryReservation(sessionId)) {
1517
+ // 用户可见提示与内部恢复 prompt 分离:提示发送失败不影响恢复,
1518
+ // 内部恢复仍由 reservation 保证先于普通缓存消息。
1519
+ await pp.sendText(
1520
+ activeAutoEnded,
1521
+ RESPONSE_STALL_RECOVERY_NOTICE,
1522
+ ).catch(() => {});
1523
+ autoRecoveryTarget = { chatId: activeAutoEnded, platform: pp };
1524
+ }
1479
1525
  }
1480
1526
  console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
1481
1527
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
@@ -1522,6 +1568,18 @@ export async function runAgentSession(
1522
1568
  if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
1523
1569
  }
1524
1570
 
1571
+ // 失去聊天绑定时无法安全选择恢复轮的展示目标,取消本次进程内预约。
1572
+ if (
1573
+ wasAutoEnded
1574
+ && hasAutoRecoveryReservation(sessionId)
1575
+ && !autoRecoveryTarget
1576
+ ) {
1577
+ cancelAutoRecoveryReservation(sessionId);
1578
+ }
1579
+ const shouldScheduleAutoRecovery =
1580
+ autoRecoveryTarget !== undefined
1581
+ && hasAutoRecoveryReservation(sessionId);
1582
+
1525
1583
  // 必须等本轮最终卡片、registry 和头像全部收尾后再取出并调度队列消息。
1526
1584
  // 在收尾期间新到达的消息也会因 finalizingSessions 被正确排入这里。
1527
1585
  let queuedForConsumption: ReturnType<typeof dequeueMessage> = undefined;
@@ -1530,7 +1588,9 @@ export async function runAgentSession(
1530
1588
  if (discarded) {
1531
1589
  console.log(`[${ts()}] [QUEUE] Discarding queued message for stopped session ${sessionId}`);
1532
1590
  }
1533
- } else {
1591
+ } else if (!shouldScheduleAutoRecovery) {
1592
+ // 第一次 response-stall 后保留普通缓存;恢复轮结束后由恢复轮的
1593
+ // finally 再消费,顺序固定为“自动恢复 → 用户缓存”。
1534
1594
  queuedForConsumption = dequeueMessage(sessionId);
1535
1595
  }
1536
1596
 
@@ -1548,7 +1608,48 @@ export async function runAgentSession(
1548
1608
  // 足够时间读到最终状态并终结旧卡片,避免新轮更新旧卡片。
1549
1609
  setTimeout(() => {
1550
1610
  consumeQueuedMessage(platform, queued);
1551
- }, 200);
1611
+ }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1612
+ }
1613
+
1614
+ if (shouldScheduleAutoRecovery && autoRecoveryTarget) {
1615
+ const target = autoRecoveryTarget;
1616
+ console.log(
1617
+ `[${ts()}] [RESPONSE-STALL] Reserved automatic recovery for session ${sessionId}`,
1618
+ );
1619
+ // 延迟与普通队列原有策略一致,让上一轮终态先完成展示。reservation
1620
+ // 在定时器等待期间仍令 isSessionRunning=true;调用 runAgentSession
1621
+ // 时会在首次 await 前同步写入 activePrompts,然后才消费 reservation,
1622
+ // 因而不存在普通用户消息可插入的事件循环空窗。
1623
+ setTimeout(() => {
1624
+ if (!hasAutoRecoveryReservation(sessionId)) return;
1625
+ const recoveryRun = runAgentSession(
1626
+ sessionId,
1627
+ RESPONSE_STALL_RECOVERY_PROMPT,
1628
+ target.platform,
1629
+ target.chatId,
1630
+ Date.now(),
1631
+ tool,
1632
+ undefined,
1633
+ { autoRecovery: true },
1634
+ );
1635
+ consumeAutoRecoveryReservation(sessionId);
1636
+ void recoveryRun.catch((err) => {
1637
+ console.error(
1638
+ `[${ts()}] [RESPONSE-STALL] Automatic recovery failed for ${sessionId}: ${(err as Error).message}`,
1639
+ );
1640
+ target.platform.sendText(
1641
+ target.chatId,
1642
+ `⚠️ 自动续跑启动失败:${(err as Error).message}`,
1643
+ ).catch(() => {});
1644
+
1645
+ // 若恢复轮在进入主 stream try/finally 前即准备失败,它不会自然
1646
+ // 消费此前保留的用户缓存;在错误回调中补做一次,避免队列悬挂。
1647
+ const queued = dequeueMessage(sessionId);
1648
+ if (queued) {
1649
+ consumeQueuedMessage(target.platform, queued);
1650
+ }
1651
+ });
1652
+ }, RESPONSE_STALL_RECOVERY_DELAY_MS);
1552
1653
  }
1553
1654
  } finally {
1554
1655
  clearSessionFinalizing(sessionId);
@@ -1910,9 +2011,19 @@ export function stopUnifiedDisplayLoop(): void {
1910
2011
  // 虽然很快但仍是异步,期间 display loop 可能多读到 1–2 帧 "running",
1911
2012
  // 用户体验上"按下停止后还要等几秒卡片才变成已停止"。先把状态标好,
1912
2013
  // finally 后续再写一次也不冲突——status 最终值仍然是 stopped。
1913
- export function stopSession(sessionId: string): boolean {
1914
- const prompt = activePrompts.get(sessionId);
1915
- if (!prompt) return false;
2014
+ export function stopSession(sessionId: string): boolean {
2015
+ // /stop 拥有高于内部自动恢复的优先级。旧轮已经完成、恢复轮尚在 200ms
2016
+ // 预约窗口时 activePrompts 为空,因此必须单独取消 reservation。
2017
+ const cancelledRecovery = cancelAutoRecoveryReservation(sessionId);
2018
+ const prompt = activePrompts.get(sessionId);
2019
+ if (!prompt) {
2020
+ if (cancelledRecovery) {
2021
+ cancelQueuedMessage(sessionId);
2022
+ console.log(`[${ts()}] [STOP] Reserved automatic recovery for ${sessionId} cancelled`);
2023
+ return true;
2024
+ }
2025
+ return false;
2026
+ }
1916
2027
  prompt.stopped = true;
1917
2028
  clearPromptResponseStallMonitor(sessionId);
1918
2029
  clearPromptProcessMonitor(sessionId);