chatccc 0.2.208 → 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/__tests__/startup-lifecycle.test.ts +133 -0
- package/src/index.ts +77 -20
- package/src/session-chat-binding.ts +34 -2
- package/src/session.ts +139 -28
- package/src/startup-lifecycle.ts +154 -0
- package/src/web-ui.ts +5 -1
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", () => {
|
|
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
INTERNAL_RESTART_ENV_VAR,
|
|
5
5
|
buildWebUiUrl,
|
|
6
|
+
createServiceLifecycleGuard,
|
|
6
7
|
createInternalRestartEnv,
|
|
7
8
|
openWebUiInDefaultBrowser,
|
|
8
9
|
shouldAutoOpenWebUi,
|
|
@@ -96,3 +97,135 @@ describe("ChatCCC startup lifecycle", () => {
|
|
|
96
97
|
expect(onInfo).toHaveBeenCalledWith(expect.stringContaining("http://localhost:18080/"));
|
|
97
98
|
});
|
|
98
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("ChatCCC service lifecycle guard", () => {
|
|
102
|
+
function createHarness() {
|
|
103
|
+
let intervalCallback: (() => void) | undefined;
|
|
104
|
+
const timer = { ref: vi.fn() };
|
|
105
|
+
const setIntervalImpl = vi.fn((callback: () => void) => {
|
|
106
|
+
intervalCallback = callback;
|
|
107
|
+
return timer;
|
|
108
|
+
});
|
|
109
|
+
const clearIntervalImpl = vi.fn();
|
|
110
|
+
const tracer = vi.fn();
|
|
111
|
+
const server = {
|
|
112
|
+
listening: true,
|
|
113
|
+
address: vi.fn(() => ({ address: "127.0.0.1", port: 18080 })),
|
|
114
|
+
ref: vi.fn(),
|
|
115
|
+
};
|
|
116
|
+
const recoverServer = vi.fn(async () => {
|
|
117
|
+
server.listening = true;
|
|
118
|
+
});
|
|
119
|
+
const guard = createServiceLifecycleGuard({
|
|
120
|
+
intervalMs: 10_000,
|
|
121
|
+
setIntervalImpl,
|
|
122
|
+
clearIntervalImpl,
|
|
123
|
+
tracer,
|
|
124
|
+
getActiveResourcesInfo: () => ["TCPServerWrap", "Timeout"],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
clearIntervalImpl,
|
|
129
|
+
getIntervalCallback: () => intervalCallback,
|
|
130
|
+
guard,
|
|
131
|
+
recoverServer,
|
|
132
|
+
server,
|
|
133
|
+
setIntervalImpl,
|
|
134
|
+
timer,
|
|
135
|
+
tracer,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it("starts exactly one referenced keep-alive timer", () => {
|
|
140
|
+
const h = createHarness();
|
|
141
|
+
|
|
142
|
+
h.guard.start();
|
|
143
|
+
h.guard.start();
|
|
144
|
+
|
|
145
|
+
expect(h.setIntervalImpl).toHaveBeenCalledOnce();
|
|
146
|
+
expect(h.setIntervalImpl).toHaveBeenCalledWith(expect.any(Function), 10_000);
|
|
147
|
+
expect(h.timer.ref).toHaveBeenCalledOnce();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("keeps a listening HTTP server referenced", async () => {
|
|
151
|
+
const h = createHarness();
|
|
152
|
+
h.guard.attachServer(h.server, h.recoverServer);
|
|
153
|
+
h.guard.start();
|
|
154
|
+
|
|
155
|
+
await h.guard.checkNow();
|
|
156
|
+
|
|
157
|
+
expect(h.server.ref).toHaveBeenCalled();
|
|
158
|
+
expect(h.recoverServer).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("coalesces concurrent recovery when the HTTP server is not listening", async () => {
|
|
162
|
+
const h = createHarness();
|
|
163
|
+
h.server.listening = false;
|
|
164
|
+
let finishRecovery: (() => void) | undefined;
|
|
165
|
+
h.recoverServer.mockImplementation(() => new Promise<void>((resolve) => {
|
|
166
|
+
finishRecovery = () => {
|
|
167
|
+
h.server.listening = true;
|
|
168
|
+
resolve();
|
|
169
|
+
};
|
|
170
|
+
}));
|
|
171
|
+
h.guard.attachServer(h.server, h.recoverServer);
|
|
172
|
+
h.guard.start();
|
|
173
|
+
|
|
174
|
+
const first = h.guard.checkNow();
|
|
175
|
+
const second = h.guard.checkNow();
|
|
176
|
+
await Promise.resolve();
|
|
177
|
+
expect(h.recoverServer).toHaveBeenCalledOnce();
|
|
178
|
+
|
|
179
|
+
finishRecovery?.();
|
|
180
|
+
await Promise.all([first, second]);
|
|
181
|
+
expect(h.tracer).toHaveBeenCalledWith(
|
|
182
|
+
"service-lifecycle: HTTP server recovered",
|
|
183
|
+
expect.objectContaining({ serverListening: true }),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("re-arms on unexpected beforeExit and records public diagnostics", async () => {
|
|
188
|
+
const h = createHarness();
|
|
189
|
+
h.guard.attachServer(h.server, h.recoverServer);
|
|
190
|
+
|
|
191
|
+
h.guard.handleBeforeExit(0);
|
|
192
|
+
await h.guard.checkNow();
|
|
193
|
+
|
|
194
|
+
expect(h.setIntervalImpl).toHaveBeenCalledOnce();
|
|
195
|
+
expect(h.server.ref).toHaveBeenCalled();
|
|
196
|
+
expect(h.tracer).toHaveBeenCalledWith(
|
|
197
|
+
"service-lifecycle: unexpected beforeExit",
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
code: 0,
|
|
200
|
+
activeResources: ["TCPServerWrap", "Timeout"],
|
|
201
|
+
serverListening: true,
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("stays stopped after an intentional shutdown", () => {
|
|
207
|
+
const h = createHarness();
|
|
208
|
+
h.guard.start();
|
|
209
|
+
|
|
210
|
+
h.guard.beginShutdown("SIGTERM");
|
|
211
|
+
h.guard.handleBeforeExit(0);
|
|
212
|
+
|
|
213
|
+
expect(h.clearIntervalImpl).toHaveBeenCalledWith(h.timer);
|
|
214
|
+
expect(h.setIntervalImpl).toHaveBeenCalledOnce();
|
|
215
|
+
expect(h.tracer).toHaveBeenCalledWith(
|
|
216
|
+
"service-lifecycle: shutdown requested",
|
|
217
|
+
{ reason: "SIGTERM" },
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("the timer callback runs a health check", async () => {
|
|
222
|
+
const h = createHarness();
|
|
223
|
+
h.guard.attachServer(h.server, h.recoverServer);
|
|
224
|
+
h.guard.start();
|
|
225
|
+
|
|
226
|
+
h.getIntervalCallback()?.();
|
|
227
|
+
await Promise.resolve();
|
|
228
|
+
|
|
229
|
+
expect(h.server.ref).toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRel
|
|
|
31
31
|
import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
|
|
32
32
|
import {
|
|
33
33
|
buildWebUiUrl,
|
|
34
|
+
createServiceLifecycleGuard,
|
|
34
35
|
openWebUiInDefaultBrowser,
|
|
35
36
|
shouldAutoOpenWebUi,
|
|
36
37
|
} from "./startup-lifecycle.ts";
|
|
@@ -744,10 +745,19 @@ async function main(): Promise<void> {
|
|
|
744
745
|
autoOpenWebUi,
|
|
745
746
|
});
|
|
746
747
|
|
|
747
|
-
//
|
|
748
|
+
// ChatCCC 是常驻服务,不能把“当前刚好有没有 Agent session”当作进程生命周期。
|
|
749
|
+
// referenced timer 明确锚定服务进程;HTTP Server 接入后还会定期 ref/健康检查。
|
|
750
|
+
// `/restart`、`/update` 都使用 process.exit(),不会被 timer 阻止。
|
|
751
|
+
const serviceLifecycle = createServiceLifecycleGuard({ tracer: appendStartupTrace });
|
|
752
|
+
serviceLifecycle.start();
|
|
753
|
+
|
|
754
|
+
// 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
|
|
748
755
|
// 越早装越好——后续任何一行抛错都有兜底;它独立于 SIGINT 清理(见末尾的
|
|
749
756
|
// server.close)——只负责诊断与默认致命退出,不替代清理逻辑。
|
|
750
|
-
installCrashLogging({
|
|
757
|
+
installCrashLogging({
|
|
758
|
+
flush: () => fileLog.flush(),
|
|
759
|
+
onBeforeExit: (code) => serviceLifecycle.handleBeforeExit(code),
|
|
760
|
+
});
|
|
751
761
|
|
|
752
762
|
// 模拟模式:独立端口 18079,不与 SDK 实例冲突,不走飞书凭证/权限/WSClient
|
|
753
763
|
if (USE_SIMULATE) {
|
|
@@ -787,10 +797,14 @@ async function main(): Promise<void> {
|
|
|
787
797
|
simServer.once("error", onError);
|
|
788
798
|
simServer.once("listening", onListening);
|
|
789
799
|
simServer.listen(SIM_PORT, "127.0.0.1");
|
|
790
|
-
}).catch((err: NodeJS.ErrnoException) => {
|
|
800
|
+
}).catch((err: NodeJS.ErrnoException) => {
|
|
791
801
|
console.error(`\n[启动] 监听失败:端口 ${SIM_PORT}(${err.code ?? "?"} — ${err.message})`);
|
|
792
|
-
process.exit(1);
|
|
793
|
-
});
|
|
802
|
+
process.exit(1);
|
|
803
|
+
});
|
|
804
|
+
serviceLifecycle.attachServer(
|
|
805
|
+
simServer,
|
|
806
|
+
() => recoverHttpServer(simServer, SIM_PORT),
|
|
807
|
+
);
|
|
794
808
|
|
|
795
809
|
console.log(`\n${"=".repeat(60)}`);
|
|
796
810
|
console.log(` ChatCCC — 模拟飞书环境模式`);
|
|
@@ -808,8 +822,8 @@ async function main(): Promise<void> {
|
|
|
808
822
|
);
|
|
809
823
|
}
|
|
810
824
|
|
|
811
|
-
installShutdownHandlers(simServer);
|
|
812
|
-
return;
|
|
825
|
+
installShutdownHandlers(simServer, serviceLifecycle);
|
|
826
|
+
return;
|
|
813
827
|
}
|
|
814
828
|
|
|
815
829
|
if (Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535) {
|
|
@@ -856,7 +870,7 @@ async function main(): Promise<void> {
|
|
|
856
870
|
if (!APP_ID.trim() || !APP_SECRET.trim()) {
|
|
857
871
|
// 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
|
|
858
872
|
// 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
|
|
859
|
-
startSetupMode(CHATCCC_PORT, {
|
|
873
|
+
const setupServer = startSetupMode(CHATCCC_PORT, {
|
|
860
874
|
openBrowser: autoOpenWebUi,
|
|
861
875
|
onActivate: async (httpServer: Server) => {
|
|
862
876
|
reloadRuntimeConfig("setup-activate");
|
|
@@ -865,17 +879,23 @@ async function main(): Promise<void> {
|
|
|
865
879
|
});
|
|
866
880
|
try {
|
|
867
881
|
await startConfiguredPlatforms(httpServer, { failOnFeishuError: true });
|
|
868
|
-
|
|
869
|
-
return { ok: true };
|
|
882
|
+
return { ok: true };
|
|
870
883
|
} catch (err) {
|
|
871
884
|
appendStartupTrace("setup-activate: startConfiguredPlatforms failed", {
|
|
872
885
|
message: (err as Error).message,
|
|
873
886
|
});
|
|
874
887
|
return { ok: false, error: (err as Error).message };
|
|
875
888
|
}
|
|
876
|
-
},
|
|
877
|
-
});
|
|
878
|
-
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
setupServer.once("listening", () => {
|
|
892
|
+
serviceLifecycle.attachServer(
|
|
893
|
+
setupServer,
|
|
894
|
+
() => recoverHttpServer(setupServer, CHATCCC_PORT),
|
|
895
|
+
);
|
|
896
|
+
});
|
|
897
|
+
installShutdownHandlers(setupServer, serviceLifecycle);
|
|
898
|
+
return;
|
|
879
899
|
}
|
|
880
900
|
console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
|
|
881
901
|
appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
|
|
@@ -903,6 +923,13 @@ async function main(): Promise<void> {
|
|
|
903
923
|
printServiceDidNotStart(`本地中继端口 ${CHATCCC_PORT} 无法监听(${err.code ?? "?"} — ${err.message})`);
|
|
904
924
|
process.exit(1);
|
|
905
925
|
});
|
|
926
|
+
serviceLifecycle.attachServer(
|
|
927
|
+
httpServer,
|
|
928
|
+
() => recoverHttpServer(httpServer, CHATCCC_PORT),
|
|
929
|
+
);
|
|
930
|
+
// 平台鉴权/长连接启动可能耗时;此时也必须允许 Ctrl+C / SIGTERM 正常退出,
|
|
931
|
+
// 不能只留下更早安装的信号日志 listener 把默认退出行为吞掉。
|
|
932
|
+
installShutdownHandlers(httpServer, serviceLifecycle);
|
|
906
933
|
|
|
907
934
|
// 必须等 HTTP server 真正监听后再发起打开请求,避免浏览器先到一步看到
|
|
908
935
|
// ERR_CONNECTION_REFUSED。Chrome CDP 守护仍保持自己原有的独立行为。
|
|
@@ -923,9 +950,23 @@ async function main(): Promise<void> {
|
|
|
923
950
|
}
|
|
924
951
|
|
|
925
952
|
await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* 生命周期健康检查发现 HTTP Server 已停止监听时,优先原地恢复同一个 Server。
|
|
957
|
+
* router、WebSocket upgrade listener 都挂在这个对象上,复用它能保留现有服务绑定;
|
|
958
|
+
* 恢复失败会被 lifecycle guard 记录,并在下一轮检查重试。
|
|
959
|
+
*/
|
|
960
|
+
async function recoverHttpServer(httpServer: Server, port: number): Promise<void> {
|
|
961
|
+
if (httpServer.listening) {
|
|
962
|
+
httpServer.ref();
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
appendStartupTrace("service-lifecycle: HTTP recovery begin", { port });
|
|
966
|
+
await listenWithRetry(httpServer, port, "127.0.0.1");
|
|
967
|
+
httpServer.ref();
|
|
968
|
+
appendStartupTrace("service-lifecycle: HTTP recovery listen succeeded", { port });
|
|
969
|
+
}
|
|
929
970
|
|
|
930
971
|
/**
|
|
931
972
|
* 带重试的 server.listen,Windows 端口释放有延迟时自动重试。
|
|
@@ -963,10 +1004,26 @@ async function listenWithRetry(
|
|
|
963
1004
|
* Node EventEmitter 按注册顺序触发,installCrashLogging 装得更早 → 同步 trace
|
|
964
1005
|
* 先写盘,再走这里。
|
|
965
1006
|
*/
|
|
966
|
-
function installShutdownHandlers(
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1007
|
+
function installShutdownHandlers(
|
|
1008
|
+
httpServer: Server,
|
|
1009
|
+
serviceLifecycle: ReturnType<typeof createServiceLifecycleGuard>,
|
|
1010
|
+
): void {
|
|
1011
|
+
process.on("SIGINT", () => {
|
|
1012
|
+
console.log("\nShutting down...");
|
|
1013
|
+
serviceLifecycle.beginShutdown("SIGINT");
|
|
1014
|
+
wechatSignal.stopped = true;
|
|
1015
|
+
stopChromeDevtoolsGuard();
|
|
1016
|
+
httpServer.close();
|
|
1017
|
+
process.exit(0);
|
|
1018
|
+
});
|
|
1019
|
+
process.on("SIGTERM", () => {
|
|
1020
|
+
serviceLifecycle.beginShutdown("SIGTERM");
|
|
1021
|
+
wechatSignal.stopped = true;
|
|
1022
|
+
stopChromeDevtoolsGuard();
|
|
1023
|
+
httpServer.close();
|
|
1024
|
+
process.exit(0);
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
970
1027
|
|
|
971
1028
|
main().catch((err: Error) => {
|
|
972
1029
|
appendStartupTrace("main: catch fatal", { message: err.message, stack: err.stack?.slice(0, 800) });
|
|
@@ -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);
|
package/src/startup-lifecycle.ts
CHANGED
|
@@ -1,5 +1,159 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
|
|
3
|
+
const DEFAULT_SERVICE_HEALTH_INTERVAL_MS = 10_000;
|
|
4
|
+
|
|
5
|
+
interface RefTimer {
|
|
6
|
+
ref?: () => unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ServiceLifecycleServer {
|
|
10
|
+
listening: boolean;
|
|
11
|
+
address: () => unknown;
|
|
12
|
+
ref: () => unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ServiceLifecycleGuardOptions {
|
|
16
|
+
intervalMs?: number;
|
|
17
|
+
setIntervalImpl?: (callback: () => void, delayMs: number) => RefTimer;
|
|
18
|
+
clearIntervalImpl?: (timer: RefTimer) => void;
|
|
19
|
+
tracer?: (message: string, extra?: Record<string, unknown>) => void;
|
|
20
|
+
getActiveResourcesInfo?: () => string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ServiceLifecycleGuard {
|
|
24
|
+
start: () => void;
|
|
25
|
+
attachServer: (
|
|
26
|
+
server: ServiceLifecycleServer,
|
|
27
|
+
recoverServer?: () => void | Promise<void>,
|
|
28
|
+
) => void;
|
|
29
|
+
checkNow: () => Promise<void>;
|
|
30
|
+
handleBeforeExit: (code: number) => void;
|
|
31
|
+
beginShutdown: (reason: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 为 ChatCCC 这种常驻服务建立一个明确的进程生命周期锚点。
|
|
36
|
+
*
|
|
37
|
+
* 正常情况下,正在 listen 的 HTTP Server 自己就足以维持事件循环;额外的
|
|
38
|
+
* referenced timer 是最后一道保险,避免某个依赖升级或异常 close/unref 让进程在
|
|
39
|
+
* 没有信号、异常或退出码的情况下静默消失。定时检查同时会重新 ref Server,并在
|
|
40
|
+
* Server 确实停止监听时串行触发恢复,避免只把一个失去服务能力的僵尸进程留下来。
|
|
41
|
+
*/
|
|
42
|
+
export function createServiceLifecycleGuard(
|
|
43
|
+
options: ServiceLifecycleGuardOptions = {},
|
|
44
|
+
): ServiceLifecycleGuard {
|
|
45
|
+
const intervalMs = options.intervalMs ?? DEFAULT_SERVICE_HEALTH_INTERVAL_MS;
|
|
46
|
+
const setIntervalImpl = options.setIntervalImpl
|
|
47
|
+
?? ((callback, delayMs) => setInterval(callback, delayMs));
|
|
48
|
+
const clearIntervalImpl = options.clearIntervalImpl
|
|
49
|
+
?? ((timer) => clearInterval(timer as NodeJS.Timeout));
|
|
50
|
+
const tracer = options.tracer ?? (() => {});
|
|
51
|
+
const getActiveResourcesInfo = options.getActiveResourcesInfo
|
|
52
|
+
?? (() => process.getActiveResourcesInfo());
|
|
53
|
+
|
|
54
|
+
let timer: RefTimer | null = null;
|
|
55
|
+
let server: ServiceLifecycleServer | null = null;
|
|
56
|
+
let recoverServer: (() => void | Promise<void>) | undefined;
|
|
57
|
+
let recoveryPromise: Promise<void> | null = null;
|
|
58
|
+
let shuttingDown = false;
|
|
59
|
+
|
|
60
|
+
const trace = (message: string, extra?: Record<string, unknown>): void => {
|
|
61
|
+
try { tracer(message, extra); } catch { /* 诊断路径不能反过来打断服务 */ }
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const serverAddress = (): unknown => {
|
|
65
|
+
try { return server?.address() ?? null; } catch { return null; }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const activeResources = (): string[] => {
|
|
69
|
+
try { return getActiveResourcesInfo(); } catch { return []; }
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const diagnostics = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
73
|
+
...extra,
|
|
74
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
75
|
+
activeResources: activeResources(),
|
|
76
|
+
serverAttached: server !== null,
|
|
77
|
+
serverListening: server?.listening ?? false,
|
|
78
|
+
serverAddress: serverAddress(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const start = (): void => {
|
|
82
|
+
if (shuttingDown || timer) return;
|
|
83
|
+
timer = setIntervalImpl(() => { void checkNow(); }, intervalMs);
|
|
84
|
+
// Node 的 Timeout 默认就是 ref 状态;显式 ref 让常驻服务契约不会依赖默认值。
|
|
85
|
+
try { timer.ref?.(); } catch { /* ignore */ }
|
|
86
|
+
trace("service-lifecycle: guard started", { intervalMs });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const checkNow = async (): Promise<void> => {
|
|
90
|
+
if (shuttingDown || !server) return;
|
|
91
|
+
if (server.listening) {
|
|
92
|
+
try { server.ref(); } catch (err) {
|
|
93
|
+
trace("service-lifecycle: HTTP server ref failed", diagnostics({
|
|
94
|
+
error: (err as Error).message,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (recoveryPromise) return recoveryPromise;
|
|
101
|
+
trace("service-lifecycle: HTTP server inactive", diagnostics());
|
|
102
|
+
if (!recoverServer) return;
|
|
103
|
+
|
|
104
|
+
recoveryPromise = Promise.resolve()
|
|
105
|
+
.then(() => recoverServer?.())
|
|
106
|
+
.then(() => {
|
|
107
|
+
if (server?.listening) {
|
|
108
|
+
try { server.ref(); } catch { /* 下一轮健康检查会再次尝试 */ }
|
|
109
|
+
trace("service-lifecycle: HTTP server recovered", diagnostics());
|
|
110
|
+
} else {
|
|
111
|
+
trace("service-lifecycle: HTTP recovery completed without listening", diagnostics());
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
.catch((err: unknown) => {
|
|
115
|
+
trace("service-lifecycle: HTTP server recovery failed", diagnostics({
|
|
116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
117
|
+
}));
|
|
118
|
+
})
|
|
119
|
+
.finally(() => {
|
|
120
|
+
recoveryPromise = null;
|
|
121
|
+
});
|
|
122
|
+
return recoveryPromise;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const attachServer = (
|
|
126
|
+
nextServer: ServiceLifecycleServer,
|
|
127
|
+
nextRecoverServer?: () => void | Promise<void>,
|
|
128
|
+
): void => {
|
|
129
|
+
server = nextServer;
|
|
130
|
+
recoverServer = nextRecoverServer;
|
|
131
|
+
if (server.listening) {
|
|
132
|
+
try { server.ref(); } catch { /* 下一轮健康检查会记录 */ }
|
|
133
|
+
}
|
|
134
|
+
trace("service-lifecycle: HTTP server attached", diagnostics());
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleBeforeExit = (code: number): void => {
|
|
138
|
+
if (shuttingDown) return;
|
|
139
|
+
trace("service-lifecycle: unexpected beforeExit", diagnostics({ code }));
|
|
140
|
+
start();
|
|
141
|
+
void checkNow();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const beginShutdown = (reason: string): void => {
|
|
145
|
+
if (shuttingDown) return;
|
|
146
|
+
shuttingDown = true;
|
|
147
|
+
if (timer) {
|
|
148
|
+
try { clearIntervalImpl(timer); } catch { /* process 即将退出 */ }
|
|
149
|
+
timer = null;
|
|
150
|
+
}
|
|
151
|
+
trace("service-lifecycle: shutdown requested", { reason });
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return { start, attachServer, checkNow, handleBeforeExit, beginShutdown };
|
|
155
|
+
}
|
|
156
|
+
|
|
3
157
|
/**
|
|
4
158
|
* ChatCCC 自己拉起替代进程时使用的内部标记。
|
|
5
159
|
*
|
package/src/web-ui.ts
CHANGED
|
@@ -2131,7 +2131,10 @@ export function setExtraApiHandler(handler: ExtraApiHandler): void {
|
|
|
2131
2131
|
extraApiHandler = handler;
|
|
2132
2132
|
}
|
|
2133
2133
|
|
|
2134
|
-
export function startSetupMode(
|
|
2134
|
+
export function startSetupMode(
|
|
2135
|
+
port: number,
|
|
2136
|
+
options: StartSetupModeOptions = {},
|
|
2137
|
+
): ReturnType<typeof createServer> {
|
|
2135
2138
|
const router = createUiRouter();
|
|
2136
2139
|
const server = createServer(router);
|
|
2137
2140
|
setupHttpServer = server;
|
|
@@ -2167,4 +2170,5 @@ export function startSetupMode(port: number, options: StartSetupModeOptions = {}
|
|
|
2167
2170
|
console.log("");
|
|
2168
2171
|
if (options.openBrowser !== false) openWebUiInDefaultBrowser(port);
|
|
2169
2172
|
});
|
|
2173
|
+
return server;
|
|
2170
2174
|
}
|