chatccc 0.2.207 → 0.2.209
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__/card-action-routing.test.ts +18 -0
- package/src/__tests__/orchestrator.test.ts +37 -0
- package/src/__tests__/startup-lifecycle.test.ts +133 -0
- package/src/card-action-routing.ts +14 -0
- package/src/index.ts +90 -28
- package/src/orchestrator.ts +64 -41
- package/src/startup-lifecycle.ts +154 -0
- package/src/web-ui.ts +5 -1
package/package.json
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { resolveFeishuCardActionChatType } from "../card-action-routing.ts";
|
|
4
|
+
|
|
5
|
+
describe("resolveFeishuCardActionChatType", () => {
|
|
6
|
+
it("keeps card commands in a persisted private chat on the p2p route", () => {
|
|
7
|
+
expect(resolveFeishuCardActionChatType("private-chat", {
|
|
8
|
+
"private-chat": { chatType: "p2p" },
|
|
9
|
+
})).toBe("p2p");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("defaults unknown and group chats to the group route", () => {
|
|
13
|
+
expect(resolveFeishuCardActionChatType("group-chat", {
|
|
14
|
+
"group-chat": { chatType: "group" },
|
|
15
|
+
})).toBe("group");
|
|
16
|
+
expect(resolveFeishuCardActionChatType("unknown-chat", {})).toBe("group");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -342,6 +342,43 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
342
342
|
expect(registry["feishu-p2p"]?.sessionId).toBe("sid-feishu-private");
|
|
343
343
|
});
|
|
344
344
|
|
|
345
|
+
it("sends the normal session state card in an established Feishu p2p chat", async () => {
|
|
346
|
+
const platform = mockPlatform("feishu");
|
|
347
|
+
_setAdapterForToolForTest("claude", mockAdapter("sid-feishu-state"));
|
|
348
|
+
await recordSessionRegistry({
|
|
349
|
+
chatId: "feishu-p2p-state",
|
|
350
|
+
sessionId: "sid-feishu-state",
|
|
351
|
+
tool: "claude",
|
|
352
|
+
chatType: "p2p",
|
|
353
|
+
chatName: "飞书私聊",
|
|
354
|
+
turnCount: 2,
|
|
355
|
+
running: false,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
await handleCommand(platform, "/state", "feishu-p2p-state", "ou-user", Date.now(), "p2p");
|
|
359
|
+
|
|
360
|
+
expect(platform.getChatInfo).not.toHaveBeenCalled();
|
|
361
|
+
expect(platform.sendRawCard).toHaveBeenCalledTimes(1);
|
|
362
|
+
const cardText = vi.mocked(platform.sendRawCard).mock.calls[0][1];
|
|
363
|
+
expect(cardText).toContain("sid-feishu-state");
|
|
364
|
+
expect(cardText).toContain("Claude Code");
|
|
365
|
+
expect(cardText).toContain("2");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("shows an explicit state card without creating an Agent when a Feishu p2p chat is not bound yet", async () => {
|
|
369
|
+
const platform = mockPlatform("feishu");
|
|
370
|
+
const adapter = mockAdapter("should-not-be-created");
|
|
371
|
+
_setAdapterForToolForTest("claude", adapter);
|
|
372
|
+
|
|
373
|
+
await handleCommand(platform, "/state", "feishu-p2p-empty", "ou-user", Date.now(), "p2p");
|
|
374
|
+
|
|
375
|
+
expect(adapter.createSession).not.toHaveBeenCalled();
|
|
376
|
+
expect(platform.sendRawCard).toHaveBeenCalledTimes(1);
|
|
377
|
+
const cardText = vi.mocked(platform.sendRawCard).mock.calls[0][1];
|
|
378
|
+
expect(cardText).toContain("未建立会话");
|
|
379
|
+
expect(cardText).toContain("Claude Code");
|
|
380
|
+
});
|
|
381
|
+
|
|
345
382
|
it("switches an idle Feishu p2p chat to a fresh session when the default Agent changes", async () => {
|
|
346
383
|
const platform = mockPlatform("feishu");
|
|
347
384
|
const oldPrompt = vi.fn(async function* () {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type FeishuCommandChatType = "p2p" | "group";
|
|
2
|
+
|
|
3
|
+
type SessionRegistryForRouting = Record<string, { chatType?: string }>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Feishu card action callbacks do not include the chat type. Recover it from
|
|
7
|
+
* the persisted binding so buttons clicked in a private chat stay in p2p.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveFeishuCardActionChatType(
|
|
10
|
+
chatId: string,
|
|
11
|
+
registry: SessionRegistryForRouting,
|
|
12
|
+
): FeishuCommandChatType {
|
|
13
|
+
return registry[chatId]?.chatType === "p2p" ? "p2p" : "group";
|
|
14
|
+
}
|
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";
|
|
@@ -112,7 +113,8 @@ import {
|
|
|
112
113
|
import { fixStaleStreamStates } from "./stream-state.ts";
|
|
113
114
|
import { handleCommand, type PlatformAdapter } from "./orchestrator.ts";
|
|
114
115
|
import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.ts";
|
|
115
|
-
import { handleCodexResetCardAction } from "./codex-reset-actions.ts";
|
|
116
|
+
import { handleCodexResetCardAction } from "./codex-reset-actions.ts";
|
|
117
|
+
import { resolveFeishuCardActionChatType } from "./card-action-routing.ts";
|
|
116
118
|
import { reloadRuntimeConfig } from "./runtime-reload.ts";
|
|
117
119
|
|
|
118
120
|
// ---------------------------------------------------------------------------
|
|
@@ -507,16 +509,18 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
507
509
|
});
|
|
508
510
|
if (handledCodexReset) return;
|
|
509
511
|
|
|
510
|
-
const result = parseCardAction(data);
|
|
511
|
-
if (!result) return;
|
|
512
|
-
|
|
512
|
+
const result = parseCardAction(data);
|
|
513
|
+
if (!result) return;
|
|
514
|
+
const registry = await loadSessionRegistryForBinding();
|
|
515
|
+
const chatType = resolveFeishuCardActionChatType(result.chatId, registry);
|
|
516
|
+
console.log(`[BTN] chat=${result.chatId} chatType=${chatType} text="${result.text}"`);
|
|
513
517
|
handleCommand(
|
|
514
518
|
feishuPlatform,
|
|
515
519
|
result.text,
|
|
516
520
|
result.chatId,
|
|
517
521
|
result.openId,
|
|
518
522
|
Date.now(),
|
|
519
|
-
|
|
523
|
+
chatType,
|
|
520
524
|
undefined,
|
|
521
525
|
result.commandId,
|
|
522
526
|
).catch((err) =>
|
|
@@ -542,15 +546,17 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
542
546
|
ws.on("message", async (raw: Buffer) => {
|
|
543
547
|
try {
|
|
544
548
|
const data = JSON.parse(raw.toString()) as Evt;
|
|
545
|
-
const action = parseCardAction(data);
|
|
546
|
-
if (action) {
|
|
549
|
+
const action = parseCardAction(data);
|
|
550
|
+
if (action) {
|
|
551
|
+
const registry = await loadSessionRegistryForBinding();
|
|
552
|
+
const chatType = resolveFeishuCardActionChatType(action.chatId, registry);
|
|
547
553
|
handleCommand(
|
|
548
554
|
feishuPlatform,
|
|
549
555
|
action.text,
|
|
550
556
|
action.chatId,
|
|
551
557
|
action.openId,
|
|
552
558
|
Date.now(),
|
|
553
|
-
|
|
559
|
+
chatType,
|
|
554
560
|
undefined,
|
|
555
561
|
action.commandId,
|
|
556
562
|
).catch((err) =>
|
|
@@ -739,10 +745,19 @@ async function main(): Promise<void> {
|
|
|
739
745
|
autoOpenWebUi,
|
|
740
746
|
});
|
|
741
747
|
|
|
742
|
-
//
|
|
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)。
|
|
743
755
|
// 越早装越好——后续任何一行抛错都有兜底;它独立于 SIGINT 清理(见末尾的
|
|
744
756
|
// server.close)——只负责诊断与默认致命退出,不替代清理逻辑。
|
|
745
|
-
installCrashLogging({
|
|
757
|
+
installCrashLogging({
|
|
758
|
+
flush: () => fileLog.flush(),
|
|
759
|
+
onBeforeExit: (code) => serviceLifecycle.handleBeforeExit(code),
|
|
760
|
+
});
|
|
746
761
|
|
|
747
762
|
// 模拟模式:独立端口 18079,不与 SDK 实例冲突,不走飞书凭证/权限/WSClient
|
|
748
763
|
if (USE_SIMULATE) {
|
|
@@ -782,10 +797,14 @@ async function main(): Promise<void> {
|
|
|
782
797
|
simServer.once("error", onError);
|
|
783
798
|
simServer.once("listening", onListening);
|
|
784
799
|
simServer.listen(SIM_PORT, "127.0.0.1");
|
|
785
|
-
}).catch((err: NodeJS.ErrnoException) => {
|
|
800
|
+
}).catch((err: NodeJS.ErrnoException) => {
|
|
786
801
|
console.error(`\n[启动] 监听失败:端口 ${SIM_PORT}(${err.code ?? "?"} — ${err.message})`);
|
|
787
|
-
process.exit(1);
|
|
788
|
-
});
|
|
802
|
+
process.exit(1);
|
|
803
|
+
});
|
|
804
|
+
serviceLifecycle.attachServer(
|
|
805
|
+
simServer,
|
|
806
|
+
() => recoverHttpServer(simServer, SIM_PORT),
|
|
807
|
+
);
|
|
789
808
|
|
|
790
809
|
console.log(`\n${"=".repeat(60)}`);
|
|
791
810
|
console.log(` ChatCCC — 模拟飞书环境模式`);
|
|
@@ -803,8 +822,8 @@ async function main(): Promise<void> {
|
|
|
803
822
|
);
|
|
804
823
|
}
|
|
805
824
|
|
|
806
|
-
installShutdownHandlers(simServer);
|
|
807
|
-
return;
|
|
825
|
+
installShutdownHandlers(simServer, serviceLifecycle);
|
|
826
|
+
return;
|
|
808
827
|
}
|
|
809
828
|
|
|
810
829
|
if (Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535) {
|
|
@@ -851,7 +870,7 @@ async function main(): Promise<void> {
|
|
|
851
870
|
if (!APP_ID.trim() || !APP_SECRET.trim()) {
|
|
852
871
|
// 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
|
|
853
872
|
// 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
|
|
854
|
-
startSetupMode(CHATCCC_PORT, {
|
|
873
|
+
const setupServer = startSetupMode(CHATCCC_PORT, {
|
|
855
874
|
openBrowser: autoOpenWebUi,
|
|
856
875
|
onActivate: async (httpServer: Server) => {
|
|
857
876
|
reloadRuntimeConfig("setup-activate");
|
|
@@ -860,17 +879,23 @@ async function main(): Promise<void> {
|
|
|
860
879
|
});
|
|
861
880
|
try {
|
|
862
881
|
await startConfiguredPlatforms(httpServer, { failOnFeishuError: true });
|
|
863
|
-
|
|
864
|
-
return { ok: true };
|
|
882
|
+
return { ok: true };
|
|
865
883
|
} catch (err) {
|
|
866
884
|
appendStartupTrace("setup-activate: startConfiguredPlatforms failed", {
|
|
867
885
|
message: (err as Error).message,
|
|
868
886
|
});
|
|
869
887
|
return { ok: false, error: (err as Error).message };
|
|
870
888
|
}
|
|
871
|
-
},
|
|
872
|
-
});
|
|
873
|
-
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
setupServer.once("listening", () => {
|
|
892
|
+
serviceLifecycle.attachServer(
|
|
893
|
+
setupServer,
|
|
894
|
+
() => recoverHttpServer(setupServer, CHATCCC_PORT),
|
|
895
|
+
);
|
|
896
|
+
});
|
|
897
|
+
installShutdownHandlers(setupServer, serviceLifecycle);
|
|
898
|
+
return;
|
|
874
899
|
}
|
|
875
900
|
console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
|
|
876
901
|
appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
|
|
@@ -898,6 +923,13 @@ async function main(): Promise<void> {
|
|
|
898
923
|
printServiceDidNotStart(`本地中继端口 ${CHATCCC_PORT} 无法监听(${err.code ?? "?"} — ${err.message})`);
|
|
899
924
|
process.exit(1);
|
|
900
925
|
});
|
|
926
|
+
serviceLifecycle.attachServer(
|
|
927
|
+
httpServer,
|
|
928
|
+
() => recoverHttpServer(httpServer, CHATCCC_PORT),
|
|
929
|
+
);
|
|
930
|
+
// 平台鉴权/长连接启动可能耗时;此时也必须允许 Ctrl+C / SIGTERM 正常退出,
|
|
931
|
+
// 不能只留下更早安装的信号日志 listener 把默认退出行为吞掉。
|
|
932
|
+
installShutdownHandlers(httpServer, serviceLifecycle);
|
|
901
933
|
|
|
902
934
|
// 必须等 HTTP server 真正监听后再发起打开请求,避免浏览器先到一步看到
|
|
903
935
|
// ERR_CONNECTION_REFUSED。Chrome CDP 守护仍保持自己原有的独立行为。
|
|
@@ -918,9 +950,23 @@ async function main(): Promise<void> {
|
|
|
918
950
|
}
|
|
919
951
|
|
|
920
952
|
await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
+
}
|
|
924
970
|
|
|
925
971
|
/**
|
|
926
972
|
* 带重试的 server.listen,Windows 端口释放有延迟时自动重试。
|
|
@@ -958,10 +1004,26 @@ async function listenWithRetry(
|
|
|
958
1004
|
* Node EventEmitter 按注册顺序触发,installCrashLogging 装得更早 → 同步 trace
|
|
959
1005
|
* 先写盘,再走这里。
|
|
960
1006
|
*/
|
|
961
|
-
function installShutdownHandlers(
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
+
}
|
|
965
1027
|
|
|
966
1028
|
main().catch((err: Error) => {
|
|
967
1029
|
appendStartupTrace("main: catch fatal", { message: err.message, stack: err.stack?.slice(0, 800) });
|
package/src/orchestrator.ts
CHANGED
|
@@ -416,6 +416,48 @@ function isFeishuP2p(platform: PlatformAdapter, chatType: string): boolean {
|
|
|
416
416
|
return chatType === "p2p" && platform.kind === "feishu";
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
async function sendStateCard(
|
|
420
|
+
platform: PlatformAdapter,
|
|
421
|
+
chatId: string,
|
|
422
|
+
sessionId: string | null,
|
|
423
|
+
toolLabel: string,
|
|
424
|
+
traceId: string,
|
|
425
|
+
): Promise<void> {
|
|
426
|
+
const status = sessionId ? await getSessionStatus(chatId) : null;
|
|
427
|
+
const isActive = sessionId ? isSessionRunning(sessionId) : false;
|
|
428
|
+
const stateLabel = sessionId
|
|
429
|
+
? (isActive ? "🟢 运行中" : "⚪ 空闲")
|
|
430
|
+
: "⚪ 未建立会话";
|
|
431
|
+
const statusText = [
|
|
432
|
+
`**群名:** ${status?.chatName || "—"}`,
|
|
433
|
+
`**Session ID:** ${sessionId ? `\`${status?.sessionId ?? sessionId}\`` : "—"}`,
|
|
434
|
+
`**工具:** ${toolLabel}`,
|
|
435
|
+
`**状态:** ${stateLabel}`,
|
|
436
|
+
`**已对话轮数:** ${status?.turnCount ?? 0}`,
|
|
437
|
+
`**模型:** ${sessionId ? (status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)) : "—"}`,
|
|
438
|
+
];
|
|
439
|
+
if (status?.effort != null) {
|
|
440
|
+
statusText.push(`**Effort:** ${status.effort}`);
|
|
441
|
+
}
|
|
442
|
+
if (isActive && status) {
|
|
443
|
+
const elapsed = Math.floor((Date.now() - status.startTime) / 1000);
|
|
444
|
+
const mins = Math.floor(elapsed / 60);
|
|
445
|
+
const secs = elapsed % 60;
|
|
446
|
+
statusText.push(`**本轮已运行:** ${mins}分${secs}秒`);
|
|
447
|
+
statusText.push(`**已产出总字符:** ${status.accumulatedLength.toLocaleString()}`);
|
|
448
|
+
}
|
|
449
|
+
if (status?.lastContextTokens) {
|
|
450
|
+
statusText.push(`**上下文 Token 数:** ~${status.lastContextTokens.toLocaleString()}`);
|
|
451
|
+
}
|
|
452
|
+
const card = buildStatusCard(statusText.join("\n"), isActive ? "blue" : "green");
|
|
453
|
+
const ok = await platform.sendRawCard(chatId, card);
|
|
454
|
+
console.log(`[${ts()}] [STATUS] card sent, ok=${ok}`);
|
|
455
|
+
logTrace(traceId, "DONE", {
|
|
456
|
+
outcome: sessionId ? "status" : "status_no_session",
|
|
457
|
+
ok,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
419
461
|
interface FeishuP2pRegistryRecord {
|
|
420
462
|
sessionId: string;
|
|
421
463
|
tool: string;
|
|
@@ -1336,44 +1378,11 @@ export async function handleCommand(
|
|
|
1336
1378
|
return;
|
|
1337
1379
|
}
|
|
1338
1380
|
|
|
1339
|
-
if (isCommandText && textLower === "/state") {
|
|
1340
|
-
logTrace(tid, "BRANCH", { cmd: "/state" });
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
`**群名:** ${status?.chatName || "—"}`,
|
|
1345
|
-
`**Session ID:** \`${status?.sessionId ?? sessionId}\``,
|
|
1346
|
-
`**工具:** ${toolLabel}`,
|
|
1347
|
-
`**状态:** ${isActive ? "🟢 运行中" : "⚪ 空闲"}`,
|
|
1348
|
-
`**已对话轮数:** ${status?.turnCount ?? 0}`,
|
|
1349
|
-
`**模型:** ${status?.model ?? anthropicConfigDisplay(CLAUDE_MODEL)}`,
|
|
1350
|
-
];
|
|
1351
|
-
if (status?.effort != null) {
|
|
1352
|
-
statusText.push(`**Effort:** ${status.effort}`);
|
|
1353
|
-
}
|
|
1354
|
-
if (isActive) {
|
|
1355
|
-
const elapsed = Math.floor((Date.now() - status!.startTime) / 1000);
|
|
1356
|
-
const mins = Math.floor(elapsed / 60);
|
|
1357
|
-
const secs = elapsed % 60;
|
|
1358
|
-
statusText.push(`**本轮已运行:** ${mins}分${secs}秒`);
|
|
1359
|
-
statusText.push(
|
|
1360
|
-
`**已产出总字符:** ${status!.accumulatedLength.toLocaleString()}`,
|
|
1361
|
-
);
|
|
1362
|
-
}
|
|
1363
|
-
if (status?.lastContextTokens) {
|
|
1364
|
-
statusText.push(
|
|
1365
|
-
`**上下文 Token 数:** ~${status.lastContextTokens.toLocaleString()}`,
|
|
1366
|
-
);
|
|
1367
|
-
}
|
|
1368
|
-
const card = buildStatusCard(
|
|
1369
|
-
statusText.join("\n"),
|
|
1370
|
-
isActive ? "blue" : "green",
|
|
1371
|
-
);
|
|
1372
|
-
const ok = await platform.sendRawCard(chatId, card);
|
|
1373
|
-
console.log(`[${ts()}] [STATUS] card sent, ok=${ok}`);
|
|
1374
|
-
logTrace(tid, "DONE", { outcome: "status", ok });
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1381
|
+
if (isCommandText && textLower === "/state") {
|
|
1382
|
+
logTrace(tid, "BRANCH", { cmd: "/state" });
|
|
1383
|
+
await sendStateCard(platform, chatId, sessionId, toolLabel, tid);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1377
1386
|
|
|
1378
1387
|
if (isCommandText && textLower === "/sessions") {
|
|
1379
1388
|
logTrace(tid, "BRANCH", { cmd: "/sessions" });
|
|
@@ -2000,9 +2009,23 @@ export async function handleCommand(
|
|
|
2000
2009
|
const card = buildModelCard(currentModel, models, defaultTool);
|
|
2001
2010
|
await platform.sendRawCard(chatId, card);
|
|
2002
2011
|
}
|
|
2003
|
-
logTrace(tid, "DONE", { outcome: "model_query", defaultTool });
|
|
2004
|
-
return;
|
|
2005
|
-
}
|
|
2012
|
+
logTrace(tid, "DONE", { outcome: "model_query", defaultTool });
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// A private /state query is useful even before the first Agent session exists.
|
|
2017
|
+
// Keep it read-only and render the same status-card shape as established chats.
|
|
2018
|
+
if (isCommandText && textLower === "/state" && isFeishuP2p(platform, chatType)) {
|
|
2019
|
+
logTrace(tid, "BRANCH", { cmd: "/state", scope: "unbound_p2p" });
|
|
2020
|
+
await sendStateCard(
|
|
2021
|
+
platform,
|
|
2022
|
+
chatId,
|
|
2023
|
+
null,
|
|
2024
|
+
toolDisplayName(resolveDefaultAgentTool()),
|
|
2025
|
+
tid,
|
|
2026
|
+
);
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2006
2029
|
|
|
2007
2030
|
// 无会话上下文 → /sessions 仍是有效指令,不触发飞书私聊自动建群。
|
|
2008
2031
|
if (isCommandText && textLower === "/effort") {
|
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
|
}
|