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 +1 -1
- package/src/__tests__/session.test.ts +222 -0
- package/src/session-chat-binding.ts +34 -2
- package/src/session.ts +139 -28
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
1915
|
-
|
|
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);
|