@keyou007/agent-bot 0.1.1 → 0.1.3
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/.env.example +3 -1
- package/CHANGELOG.md +15 -0
- package/README.md +23 -6
- package/README.zh.md +23 -6
- package/config.example.yaml +1 -2
- package/dist/cli/FeishuAppRegistration.js +12 -2
- package/dist/cli/FeishuAppRegistration.js.map +1 -1
- package/dist/cli/Initializer.js +164 -14
- package/dist/cli/Initializer.js.map +1 -1
- package/dist/cli/LocalControlClient.js +14 -1
- package/dist/cli/LocalControlClient.js.map +1 -1
- package/dist/cli/ServerStarter.js +69 -0
- package/dist/cli/ServerStarter.js.map +1 -0
- package/dist/cli/help.js +6 -2
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/profile.js +40 -0
- package/dist/cli/profile.js.map +1 -0
- package/dist/cli.js +93 -89
- package/dist/cli.js.map +1 -1
- package/dist/config/loadConfig.js +7 -4
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/config/paths.js +1 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/schema.js +2 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/feishu/CardRenderer.js +21 -3
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +8 -11
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/transport.js +4 -11
- package/dist/feishu/transport.js.map +1 -1
- package/dist/index.js +20 -8
- package/dist/index.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +18 -3
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/startup/StartupNotifier.js +13 -2
- package/dist/startup/StartupNotifier.js.map +1 -1
- package/dist/startup/startFeishu.js +2 -1
- package/dist/startup/startFeishu.js.map +1 -1
- package/dist/supervision/SafeRestartNotifier.js +23 -7
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +20 -2
- package/dist/supervision/SafeRestartScheduler.js.map +1 -1
- package/dist/supervision/SupervisorDiagnostics.js +209 -0
- package/dist/supervision/SupervisorDiagnostics.js.map +1 -0
- package/dist/supervisor.js +54 -16
- package/dist/supervisor.js.map +1 -1
- package/docs/technical-reference.md +28 -14
- package/docs/technical-reference.zh.md +28 -14
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +24 -4
- package/src/cli/FeishuAppRegistration.ts +14 -1
- package/src/cli/Initializer.ts +174 -14
- package/src/cli/LocalControlClient.ts +12 -1
- package/src/cli/ServerStarter.ts +106 -0
- package/src/cli/help.ts +6 -2
- package/src/cli/profile.ts +59 -0
- package/src/cli.ts +114 -84
- package/src/config/loadConfig.ts +12 -4
- package/src/config/paths.ts +1 -0
- package/src/config/schema.ts +2 -1
- package/src/feishu/CardRenderer.ts +25 -3
- package/src/feishu/FeishuConnector.ts +8 -12
- package/src/feishu/transport.ts +7 -16
- package/src/index.ts +31 -8
- package/src/proxy/ProxySessionController.ts +18 -3
- package/src/startup/StartupNotifier.ts +21 -4
- package/src/startup/startFeishu.ts +2 -0
- package/src/supervision/SafeRestartNotifier.ts +22 -7
- package/src/supervision/SafeRestartScheduler.ts +27 -3
- package/src/supervision/SupervisorDiagnostics.ts +268 -0
- package/src/supervisor.ts +53 -15
package/src/feishu/transport.ts
CHANGED
|
@@ -1,24 +1,15 @@
|
|
|
1
1
|
import type { AppConfig } from "../config/schema.js";
|
|
2
2
|
|
|
3
|
-
export type FeishuTransportMode = "sdk" | "console";
|
|
4
|
-
|
|
5
3
|
type FeishuTransportConfig = Pick<
|
|
6
4
|
AppConfig["feishu"],
|
|
7
|
-
"
|
|
5
|
+
"appId" | "appSecret"
|
|
8
6
|
>;
|
|
9
7
|
|
|
10
|
-
export function
|
|
11
|
-
if (config.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const hasCredentials = Boolean(config.appId && config.appSecret);
|
|
16
|
-
if (config.transport === "sdk" || !config.useConsoleWhenMissingCredentials) {
|
|
17
|
-
if (!hasCredentials) {
|
|
18
|
-
throw new Error("Feishu appId/appSecret are required.");
|
|
19
|
-
}
|
|
20
|
-
return "sdk";
|
|
8
|
+
export function requireServerFeishuTransport(config: FeishuTransportConfig): "sdk" {
|
|
9
|
+
if (!config.appId || !config.appSecret) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
"飞书机器人尚未配置。请先运行 agent-bot init 完成初始化;仅需本地调试时请使用 agent-bot console。",
|
|
12
|
+
);
|
|
21
13
|
}
|
|
22
|
-
|
|
23
|
-
return hasCredentials ? "sdk" : "console";
|
|
14
|
+
return "sdk";
|
|
24
15
|
}
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { ConsoleFeishuClient } from "./feishu/ConsoleFeishuClient.js";
|
|
|
15
15
|
import { FeishuConnector } from "./feishu/FeishuConnector.js";
|
|
16
16
|
import { FeishuMessageClient } from "./feishu/FeishuMessageClient.js";
|
|
17
17
|
import { FeishuTurnPresenter } from "./feishu/FeishuTurnPresenter.js";
|
|
18
|
-
import {
|
|
18
|
+
import { requireServerFeishuTransport } from "./feishu/transport.js";
|
|
19
19
|
import { createLogger } from "./logging/logger.js";
|
|
20
20
|
import { OutboundRouter, type OutboundRoute } from "./presentation/OutboundRouter.js";
|
|
21
21
|
import { ProxySessionController } from "./proxy/ProxySessionController.js";
|
|
@@ -29,6 +29,11 @@ import { STOP_EXIT_CODE } from "./supervision/restartPolicy.js";
|
|
|
29
29
|
import { replacementSupervisorEnvironment } from "./supervision/replacementSupervisor.js";
|
|
30
30
|
import { SafeRestartScheduler } from "./supervision/SafeRestartScheduler.js";
|
|
31
31
|
import { SafeRestartNotifier } from "./supervision/SafeRestartNotifier.js";
|
|
32
|
+
import {
|
|
33
|
+
nodeDiagnosticReportArguments,
|
|
34
|
+
prepareCrashReportDirectory,
|
|
35
|
+
resolveSupervisorDiagnosticsPaths,
|
|
36
|
+
} from "./supervision/SupervisorDiagnostics.js";
|
|
32
37
|
|
|
33
38
|
const processStartedAt = new Date();
|
|
34
39
|
const supervised = process.env.AGENT_BOT_SUPERVISED === "1";
|
|
@@ -36,6 +41,7 @@ const startupReason = process.env.AGENT_BOT_RESTART_REASON?.trim()
|
|
|
36
41
|
|| (supervised ? "Supervisor 启动" : "直接启动");
|
|
37
42
|
const consoleOnly = process.env.AGENT_BOT_CONSOLE_ONLY === "1";
|
|
38
43
|
const config = loadConfig();
|
|
44
|
+
const transport = consoleOnly ? "console" : requireServerFeishuTransport(config.feishu);
|
|
39
45
|
const logger = createLogger(config);
|
|
40
46
|
const store = new StateStore(config.storage.sqlitePath);
|
|
41
47
|
|
|
@@ -52,13 +58,13 @@ const codexProcessManager = new CodexProcessManager(
|
|
|
52
58
|
const codexRuntime = new CodexRuntime(codexProcessManager, logger);
|
|
53
59
|
const runtimes = new AgentRuntimeRegistry({ acp: acpRuntime, codex: codexRuntime });
|
|
54
60
|
|
|
55
|
-
const transport = consoleOnly ? "console" : resolveFeishuTransport(config.feishu);
|
|
56
61
|
const routes: OutboundRoute[] = [];
|
|
57
62
|
let feishuConnector: FeishuConnector | undefined;
|
|
58
63
|
let consoleConnector: ConsoleConnector | undefined;
|
|
59
64
|
let startupNotifier: StartupNotifier | undefined;
|
|
60
65
|
let safeRestartNotifier: SafeRestartNotifier | undefined;
|
|
61
66
|
let controlServer: LocalControlServer | undefined;
|
|
67
|
+
let serverReady = false;
|
|
62
68
|
|
|
63
69
|
const feishuOutbound = transport === "sdk" ? new FeishuMessageClient(config, logger) : undefined;
|
|
64
70
|
if (feishuOutbound) {
|
|
@@ -77,6 +83,7 @@ if (feishuOutbound) {
|
|
|
77
83
|
defaultAgentTitle: defaultAgent?.title ?? defaultAgentName,
|
|
78
84
|
cwd: path.resolve(config.defaults.cwd),
|
|
79
85
|
workspaceKind: defaultAgent?.kind === "codex" ? "projectless" : "project",
|
|
86
|
+
defaultUserOpenId: config.feishu.userOpenId,
|
|
80
87
|
}, metadataHydrator);
|
|
81
88
|
safeRestartNotifier = new SafeRestartNotifier(store, feishuOutbound, renderer, logger);
|
|
82
89
|
}
|
|
@@ -95,16 +102,17 @@ if (routes.length === 0) throw new Error("Neither Feishu nor console input is en
|
|
|
95
102
|
const outbound = new OutboundRouter(routes);
|
|
96
103
|
let shuttingDown = false;
|
|
97
104
|
let restartRequested = false;
|
|
98
|
-
const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
|
|
99
|
-
supervised,
|
|
100
|
-
restart: requestRestart,
|
|
101
|
-
});
|
|
102
105
|
const safeRestart = new SafeRestartScheduler({
|
|
103
106
|
readActivity: () => store.getServerActivityState(),
|
|
104
107
|
onReady: (reason) => initiateRestart(reason),
|
|
105
108
|
onStatus: (status) => safeRestartNotifier?.update(status),
|
|
106
109
|
onStatusError: (error) => logger.warn({ error }, "Failed to publish safe restart status."),
|
|
107
110
|
});
|
|
111
|
+
const controller = new ProxySessionController(config, store, runtimes, outbound, logger, {
|
|
112
|
+
supervised,
|
|
113
|
+
restart: requestRestart,
|
|
114
|
+
cancelSafeRestart: (scheduleId) => safeRestart.cancelScheduled(scheduleId),
|
|
115
|
+
});
|
|
108
116
|
|
|
109
117
|
if (feishuOutbound) feishuConnector = new FeishuConnector(config, controller, logger);
|
|
110
118
|
if (consoleEnabled) consoleConnector = new ConsoleConnector(controller, logger);
|
|
@@ -123,10 +131,18 @@ const startControlServer = async (): Promise<void> => {
|
|
|
123
131
|
};
|
|
124
132
|
|
|
125
133
|
if (feishuConnector && startupNotifier) {
|
|
126
|
-
await startFeishu(
|
|
134
|
+
await startFeishu(
|
|
135
|
+
feishuConnector,
|
|
136
|
+
startupNotifier,
|
|
137
|
+
processStartedAt,
|
|
138
|
+
startupReason,
|
|
139
|
+
startControlServer,
|
|
140
|
+
() => { serverReady = true; },
|
|
141
|
+
);
|
|
127
142
|
} else {
|
|
128
143
|
await startControlServer();
|
|
129
144
|
await feishuConnector?.start();
|
|
145
|
+
serverReady = true;
|
|
130
146
|
}
|
|
131
147
|
consoleConnector?.start();
|
|
132
148
|
|
|
@@ -156,6 +172,8 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
|
|
|
156
172
|
return {
|
|
157
173
|
ok: true,
|
|
158
174
|
data: {
|
|
175
|
+
ready: serverReady,
|
|
176
|
+
phase: serverReady ? "ready" : "connecting_feishu",
|
|
159
177
|
pid: process.pid,
|
|
160
178
|
startedAt: processStartedAt.toISOString(),
|
|
161
179
|
supervised,
|
|
@@ -213,7 +231,12 @@ async function shutdown(exitCode: number, restartReason?: string): Promise<void>
|
|
|
213
231
|
|
|
214
232
|
function startReplacementSupervisor(restartReason: string): void {
|
|
215
233
|
const supervisorEntry = fileURLToPath(new URL("./supervisor.js", import.meta.url));
|
|
216
|
-
const
|
|
234
|
+
const reportDirectory = resolveSupervisorDiagnosticsPaths(config).crashReportDirectory;
|
|
235
|
+
prepareCrashReportDirectory(reportDirectory);
|
|
236
|
+
const supervisor = spawn(process.execPath, [
|
|
237
|
+
...nodeDiagnosticReportArguments(reportDirectory),
|
|
238
|
+
supervisorEntry,
|
|
239
|
+
], {
|
|
217
240
|
cwd: process.cwd(),
|
|
218
241
|
detached: true,
|
|
219
242
|
windowsHide: true,
|
|
@@ -123,6 +123,7 @@ interface CreatedFeishuGroupContext {
|
|
|
123
123
|
export interface ProxyLifecycle {
|
|
124
124
|
supervised?: boolean;
|
|
125
125
|
restart(contextKey: string): Promise<void>;
|
|
126
|
+
cancelSafeRestart?(scheduleId: number): Promise<boolean>;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
export type ShellCommandExecutor = (command: string, cwd: string) => Promise<ShellCommandResult>;
|
|
@@ -176,9 +177,8 @@ export class ProxySessionController {
|
|
|
176
177
|
}
|
|
177
178
|
|
|
178
179
|
async onMessage(message: IncomingMessage): Promise<void> {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
180
|
+
// The durable deduplication claim is the only operation allowed before acknowledgement.
|
|
181
|
+
// It prevents event retries from adding duplicate reactions.
|
|
182
182
|
if (!this.store.claimInboundEvent(message.messageId, "message")) return;
|
|
183
183
|
try {
|
|
184
184
|
const reactionId = await this.outbound.addReaction(
|
|
@@ -195,6 +195,9 @@ export class ProxySessionController {
|
|
|
195
195
|
"Failed to acknowledge the incoming Feishu message with a reaction.",
|
|
196
196
|
);
|
|
197
197
|
}
|
|
198
|
+
if (message.chatId && message.chatType) {
|
|
199
|
+
this.store.recordChatContext(baseChatContextKey(message.contextKey), message.chatType);
|
|
200
|
+
}
|
|
198
201
|
const replyTarget = message.replyInThread
|
|
199
202
|
? { messageId: message.messageId, replyInThread: true as const }
|
|
200
203
|
: undefined;
|
|
@@ -323,6 +326,18 @@ export class ProxySessionController {
|
|
|
323
326
|
await this.cancelSession(this.requireSession(contextKey, sessionId));
|
|
324
327
|
} else if (kind === "queued_prompt_cancel") {
|
|
325
328
|
await this.cancelQueuedPrompt(scopedAction);
|
|
329
|
+
} else if (kind === "safe_restart_cancel") {
|
|
330
|
+
const scheduleId = Number(scopedAction.value.scheduleId);
|
|
331
|
+
if (!Number.isSafeInteger(scheduleId) || scheduleId <= 0) {
|
|
332
|
+
throw new Error("安全重启卡片无效,请使用最新的状态卡片。");
|
|
333
|
+
}
|
|
334
|
+
if (!this.lifecycle?.cancelSafeRestart) {
|
|
335
|
+
throw new Error("当前运行方式不支持取消安全重启。");
|
|
336
|
+
}
|
|
337
|
+
const cancelled = await this.lifecycle.cancelSafeRestart(scheduleId);
|
|
338
|
+
if (!cancelled) {
|
|
339
|
+
await this.outbound.sendText(contextKey, "该安全重启计划已失效,请查看最新状态卡片。");
|
|
340
|
+
}
|
|
326
341
|
} else if (kind === "session_more") {
|
|
327
342
|
await this.refreshSessionsCardFromAction(scopedAction, undefined, true);
|
|
328
343
|
} else if (kind === "session_switch") {
|
|
@@ -2,19 +2,25 @@ import type { Logger } from "pino";
|
|
|
2
2
|
import { detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
|
|
3
3
|
import { CardRenderer } from "../feishu/CardRenderer.js";
|
|
4
4
|
import type { FeishuOutbound } from "../feishu/types.js";
|
|
5
|
-
import type { SessionRecord, StateStore } from "../state/StateStore.js";
|
|
5
|
+
import type { SessionRecord, StateStore, UserContextRecord } from "../state/StateStore.js";
|
|
6
6
|
|
|
7
7
|
export interface StartupNotificationOptions {
|
|
8
8
|
defaultAgentName: string;
|
|
9
9
|
defaultAgentTitle: string;
|
|
10
10
|
cwd: string;
|
|
11
11
|
workspaceKind?: "project" | "projectless";
|
|
12
|
+
defaultUserOpenId?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export interface StartupTaskMetadataHydrator {
|
|
15
16
|
hydrate(session: SessionRecord): Promise<SessionRecord>;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
interface StartupNotificationTarget {
|
|
20
|
+
contextKey: string;
|
|
21
|
+
context?: UserContextRecord;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export class StartupNotifier {
|
|
19
25
|
constructor(
|
|
20
26
|
private readonly store: StateStore,
|
|
@@ -26,7 +32,7 @@ export class StartupNotifier {
|
|
|
26
32
|
) {}
|
|
27
33
|
|
|
28
34
|
async notify(startedAt: Date, restartReason: string): Promise<void> {
|
|
29
|
-
let targets;
|
|
35
|
+
let targets: StartupNotificationTarget[];
|
|
30
36
|
try {
|
|
31
37
|
const activeGroupSince = new Date(startedAt.getTime() - 3 * 60 * 1_000);
|
|
32
38
|
const chats = [
|
|
@@ -40,10 +46,16 @@ export class StartupNotifier {
|
|
|
40
46
|
}));
|
|
41
47
|
} catch (error) {
|
|
42
48
|
this.logger.warn({ error }, "Failed to load startup notification targets.");
|
|
43
|
-
|
|
49
|
+
throw new Error("Failed to load startup notification targets.", { cause: error });
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
if (targets.length === 0) {
|
|
53
|
+
const defaultUserOpenId = this.options.defaultUserOpenId?.trim();
|
|
54
|
+
if (!defaultUserOpenId) return;
|
|
55
|
+
targets = [{ contextKey: `open_id:${defaultUserOpenId}` }];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const deliveries = await Promise.all(targets.map(async ({ contextKey, context }) => {
|
|
47
59
|
let session = context?.currentSessionId ? this.store.getSession(context.currentSessionId) : undefined;
|
|
48
60
|
if (session && this.metadataHydrator) {
|
|
49
61
|
try {
|
|
@@ -81,12 +93,17 @@ export class StartupNotifier {
|
|
|
81
93
|
});
|
|
82
94
|
try {
|
|
83
95
|
await this.outbound.sendInteractiveCard(contextKey, card);
|
|
96
|
+
return true;
|
|
84
97
|
} catch (error) {
|
|
85
98
|
this.logger.warn(
|
|
86
99
|
{ error, contextKey },
|
|
87
100
|
"Failed to send startup status notification.",
|
|
88
101
|
);
|
|
102
|
+
return false;
|
|
89
103
|
}
|
|
90
104
|
}));
|
|
105
|
+
if (deliveries.length > 0 && !deliveries.some(Boolean)) {
|
|
106
|
+
throw new Error("Failed to send any startup status notification.");
|
|
107
|
+
}
|
|
91
108
|
}
|
|
92
109
|
}
|
|
@@ -12,8 +12,10 @@ export async function startFeishu(
|
|
|
12
12
|
startedAt: Date,
|
|
13
13
|
restartReason: string,
|
|
14
14
|
prepare?: () => Promise<void>,
|
|
15
|
+
onConnected?: () => void,
|
|
15
16
|
): Promise<void> {
|
|
16
17
|
await prepare?.();
|
|
17
18
|
await connector.start();
|
|
18
19
|
await notifier.notify(startedAt, restartReason);
|
|
20
|
+
onConnected?.();
|
|
19
21
|
}
|
|
@@ -14,6 +14,7 @@ export class SafeRestartNotifier {
|
|
|
14
14
|
private readonly messageIds = new Map<string, string>();
|
|
15
15
|
private readonly cardHashes = new Map<string, string>();
|
|
16
16
|
private activeScheduleId?: number;
|
|
17
|
+
private terminalScheduleId?: number;
|
|
17
18
|
private pendingInitialStatus?: SafeRestartStatus;
|
|
18
19
|
private initialCardTimer?: NodeJS.Timeout;
|
|
19
20
|
private queue = Promise.resolve();
|
|
@@ -27,13 +28,24 @@ export class SafeRestartNotifier {
|
|
|
27
28
|
) {}
|
|
28
29
|
|
|
29
30
|
update(status: SafeRestartStatus): Promise<void> {
|
|
31
|
+
if (this.terminalScheduleId !== undefined && status.scheduleId <= this.terminalScheduleId) {
|
|
32
|
+
if (status.phase !== "cancelled") return Promise.resolve();
|
|
33
|
+
}
|
|
34
|
+
if (this.activeScheduleId !== undefined && status.scheduleId < this.activeScheduleId) {
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
}
|
|
37
|
+
if (status.phase === "cancelled") {
|
|
38
|
+
this.terminalScheduleId = Math.max(this.terminalScheduleId ?? 0, status.scheduleId);
|
|
39
|
+
}
|
|
30
40
|
if (this.activeScheduleId !== status.scheduleId) {
|
|
31
41
|
this.activeScheduleId = status.scheduleId;
|
|
32
42
|
this.messageIds.clear();
|
|
33
43
|
this.cardHashes.clear();
|
|
34
44
|
this.clearInitialCardTimer();
|
|
35
45
|
this.pendingInitialStatus = status;
|
|
36
|
-
const delayMs =
|
|
46
|
+
const delayMs = status.phase === "cancelled"
|
|
47
|
+
? 0
|
|
48
|
+
: this.options.initialCardDelayMs ?? DEFAULT_INITIAL_CARD_DELAY_MS;
|
|
37
49
|
if (delayMs <= 0) return this.publishPendingInitialStatus();
|
|
38
50
|
this.initialCardTimer = setTimeout(() => {
|
|
39
51
|
this.initialCardTimer = undefined;
|
|
@@ -79,13 +91,16 @@ export class SafeRestartNotifier {
|
|
|
79
91
|
private async publish(status: SafeRestartStatus): Promise<void> {
|
|
80
92
|
const targets = this.store.listChatContexts("p2p");
|
|
81
93
|
if (targets.length === 0) return;
|
|
82
|
-
const waitingTasks =
|
|
83
|
-
|
|
84
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
const waitingTasks = status.phase === "cancelled"
|
|
95
|
+
? []
|
|
96
|
+
: this.store.listAllSessions()
|
|
97
|
+
.filter((session) => session.status === "running" || this.store.countQueuedPrompts(session.localSessionId) > 0)
|
|
98
|
+
.map((session) => ({
|
|
99
|
+
id: session.remoteSessionId ?? session.localSessionId,
|
|
100
|
+
title: session.title,
|
|
101
|
+
}));
|
|
88
102
|
const card = this.renderer.renderSafeRestartStatus({
|
|
103
|
+
scheduleId: status.scheduleId,
|
|
89
104
|
reason: status.reason,
|
|
90
105
|
phase: status.phase,
|
|
91
106
|
remainingMs: status.remainingMs,
|
|
@@ -4,7 +4,12 @@ export interface ServerActivityState {
|
|
|
4
4
|
latestInboundAt?: string;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
export type SafeRestartPhase =
|
|
7
|
+
export type SafeRestartPhase =
|
|
8
|
+
| "waiting_tasks"
|
|
9
|
+
| "waiting_delivery"
|
|
10
|
+
| "countdown"
|
|
11
|
+
| "restarting"
|
|
12
|
+
| "cancelled";
|
|
8
13
|
|
|
9
14
|
export interface SafeRestartStatus {
|
|
10
15
|
scheduleId: number;
|
|
@@ -56,6 +61,24 @@ export class SafeRestartScheduler {
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
cancel(): void {
|
|
64
|
+
this.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async cancelScheduled(expectedScheduleId: number): Promise<boolean> {
|
|
68
|
+
const reason = this.reason;
|
|
69
|
+
if (!reason || this.scheduleId !== expectedScheduleId) return false;
|
|
70
|
+
const activity = this.options.readActivity();
|
|
71
|
+
this.clear();
|
|
72
|
+
await this.emitStatus({
|
|
73
|
+
scheduleId: expectedScheduleId,
|
|
74
|
+
reason,
|
|
75
|
+
phase: "cancelled",
|
|
76
|
+
activity,
|
|
77
|
+
});
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private clear(): void {
|
|
59
82
|
if (this.timer) clearInterval(this.timer);
|
|
60
83
|
this.timer = undefined;
|
|
61
84
|
this.reason = undefined;
|
|
@@ -117,8 +140,9 @@ export class SafeRestartScheduler {
|
|
|
117
140
|
}
|
|
118
141
|
return;
|
|
119
142
|
}
|
|
120
|
-
|
|
121
|
-
this.
|
|
143
|
+
const scheduleId = this.scheduleId;
|
|
144
|
+
this.clear();
|
|
145
|
+
await this.emitStatus({ scheduleId, reason, phase: "restarting", activity: confirmed, remainingMs: 0 });
|
|
122
146
|
await this.options.onReady(reason);
|
|
123
147
|
}
|
|
124
148
|
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AppConfig } from "../config/schema.js";
|
|
4
|
+
|
|
5
|
+
const MAX_LOG_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const LOG_BACKUP_COUNT = 3;
|
|
7
|
+
|
|
8
|
+
export interface SupervisorDiagnosticsPaths {
|
|
9
|
+
supervisorLogPath: string;
|
|
10
|
+
workerStderrPath: string;
|
|
11
|
+
crashReportDirectory: string;
|
|
12
|
+
lastCrashPath: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface WorkerCrashContext {
|
|
16
|
+
workerPid?: number;
|
|
17
|
+
exitCode: number | null;
|
|
18
|
+
signal: NodeJS.Signals | null;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
exitedAt: string;
|
|
21
|
+
uptimeMs: number;
|
|
22
|
+
consecutiveFailures: number;
|
|
23
|
+
restartDelayMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WorkerCrashRecord extends WorkerCrashContext {
|
|
27
|
+
version: 1;
|
|
28
|
+
supervisorPid: number;
|
|
29
|
+
nodeVersion: string;
|
|
30
|
+
executablePath: string;
|
|
31
|
+
cwd: string;
|
|
32
|
+
supervisorLogPath: string;
|
|
33
|
+
workerStderrPath: string;
|
|
34
|
+
crashReportDirectory: string;
|
|
35
|
+
diagnosticReports: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ExtendedProcessReport = typeof process.report & {
|
|
39
|
+
excludeEnv?: boolean;
|
|
40
|
+
excludeNetwork?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export class SupervisorDiagnostics {
|
|
44
|
+
readonly paths: SupervisorDiagnosticsPaths;
|
|
45
|
+
|
|
46
|
+
constructor(config: Pick<AppConfig, "logging" | "storage">) {
|
|
47
|
+
this.paths = resolveSupervisorDiagnosticsPaths(config);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
initialize(): void {
|
|
51
|
+
try {
|
|
52
|
+
ensurePrivateDirectory(path.dirname(this.paths.supervisorLogPath));
|
|
53
|
+
ensurePrivateDirectory(this.paths.crashReportDirectory);
|
|
54
|
+
rotateLogFile(this.paths.supervisorLogPath);
|
|
55
|
+
rotateLogFile(this.paths.workerStderrPath);
|
|
56
|
+
this.configureCurrentProcessReports();
|
|
57
|
+
this.writeEvent("diagnostics_initialized", {
|
|
58
|
+
nodeVersion: process.versions.node,
|
|
59
|
+
supervisorLogPath: this.paths.supervisorLogPath,
|
|
60
|
+
workerStderrPath: this.paths.workerStderrPath,
|
|
61
|
+
crashReportDirectory: this.paths.crashReportDirectory,
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
writeFallbackDiagnostic("initialize_failed", error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
writeEvent(event: string, data: Record<string, unknown> = {}): void {
|
|
69
|
+
const line = `${JSON.stringify({
|
|
70
|
+
component: "agent-bot-supervisor",
|
|
71
|
+
event,
|
|
72
|
+
time: new Date().toISOString(),
|
|
73
|
+
...data,
|
|
74
|
+
})}\n`;
|
|
75
|
+
try {
|
|
76
|
+
fs.appendFileSync(this.paths.supervisorLogPath, line, { encoding: "utf8", mode: 0o600 });
|
|
77
|
+
restrictFilePermissions(this.paths.supervisorLogPath);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
writeFallbackDiagnostic(event, error);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
openWorkerStderr(): number | "ignore" {
|
|
84
|
+
try {
|
|
85
|
+
rotateLogFile(this.paths.workerStderrPath);
|
|
86
|
+
fs.appendFileSync(
|
|
87
|
+
this.paths.workerStderrPath,
|
|
88
|
+
`${JSON.stringify({ event: "worker_started", time: new Date().toISOString() })}\n`,
|
|
89
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
90
|
+
);
|
|
91
|
+
restrictFilePermissions(this.paths.workerStderrPath);
|
|
92
|
+
return fs.openSync(this.paths.workerStderrPath, "a", 0o600);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
this.writeEvent("worker_stderr_open_failed", { error: errorMessage(error) });
|
|
95
|
+
return "ignore";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
closeWorkerStderr(target: number | "ignore"): void {
|
|
100
|
+
if (typeof target !== "number") return;
|
|
101
|
+
try {
|
|
102
|
+
fs.closeSync(target);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
this.writeEvent("worker_stderr_close_failed", { error: errorMessage(error) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
recordCrash(context: WorkerCrashContext): WorkerCrashRecord | undefined {
|
|
109
|
+
try {
|
|
110
|
+
const record: WorkerCrashRecord = {
|
|
111
|
+
version: 1,
|
|
112
|
+
...context,
|
|
113
|
+
supervisorPid: process.pid,
|
|
114
|
+
nodeVersion: process.versions.node,
|
|
115
|
+
executablePath: process.execPath,
|
|
116
|
+
cwd: process.cwd(),
|
|
117
|
+
supervisorLogPath: this.paths.supervisorLogPath,
|
|
118
|
+
workerStderrPath: this.paths.workerStderrPath,
|
|
119
|
+
crashReportDirectory: this.paths.crashReportDirectory,
|
|
120
|
+
diagnosticReports: findDiagnosticReports(
|
|
121
|
+
this.paths.crashReportDirectory,
|
|
122
|
+
context.workerPid,
|
|
123
|
+
Date.parse(context.startedAt),
|
|
124
|
+
),
|
|
125
|
+
};
|
|
126
|
+
const crashPath = path.join(
|
|
127
|
+
this.paths.crashReportDirectory,
|
|
128
|
+
crashRecordFilename(context.exitedAt, context.workerPid),
|
|
129
|
+
);
|
|
130
|
+
writePrivateJson(crashPath, record);
|
|
131
|
+
writePrivateJson(this.paths.lastCrashPath, record);
|
|
132
|
+
this.writeEvent("crash_context_saved", {
|
|
133
|
+
crashPath,
|
|
134
|
+
lastCrashPath: this.paths.lastCrashPath,
|
|
135
|
+
diagnosticReports: record.diagnosticReports,
|
|
136
|
+
});
|
|
137
|
+
return record;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
this.writeEvent("crash_context_save_failed", { error: errorMessage(error) });
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private configureCurrentProcessReports(): void {
|
|
145
|
+
const report = process.report as ExtendedProcessReport;
|
|
146
|
+
report.directory = this.paths.crashReportDirectory;
|
|
147
|
+
report.reportOnFatalError = true;
|
|
148
|
+
report.reportOnUncaughtException = true;
|
|
149
|
+
if ("excludeEnv" in report) report.excludeEnv = true;
|
|
150
|
+
if ("excludeNetwork" in report) report.excludeNetwork = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function resolveSupervisorDiagnosticsPaths(
|
|
155
|
+
config: Pick<AppConfig, "logging" | "storage">,
|
|
156
|
+
): SupervisorDiagnosticsPaths {
|
|
157
|
+
const logDirectory = path.dirname(config.logging.path);
|
|
158
|
+
const dataDirectory = path.dirname(config.storage.sqlitePath);
|
|
159
|
+
return {
|
|
160
|
+
supervisorLogPath: path.join(logDirectory, "supervisor.log"),
|
|
161
|
+
workerStderrPath: path.join(logDirectory, "worker.stderr.log"),
|
|
162
|
+
crashReportDirectory: path.join(dataDirectory, "crash-reports"),
|
|
163
|
+
lastCrashPath: path.join(dataDirectory, "last-crash.json"),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function nodeDiagnosticReportArguments(
|
|
168
|
+
reportDirectory: string,
|
|
169
|
+
allowedFlags: ReadonlySet<string> = process.allowedNodeEnvironmentFlags,
|
|
170
|
+
): string[] {
|
|
171
|
+
const args = [
|
|
172
|
+
"--report-on-fatalerror",
|
|
173
|
+
"--report-uncaught-exception",
|
|
174
|
+
`--report-directory=${reportDirectory}`,
|
|
175
|
+
];
|
|
176
|
+
if (allowedFlags.has("--report-exclude-env")) args.push("--report-exclude-env");
|
|
177
|
+
if (allowedFlags.has("--report-exclude-network")) args.push("--report-exclude-network");
|
|
178
|
+
return args;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function prepareCrashReportDirectory(reportDirectory: string): boolean {
|
|
182
|
+
try {
|
|
183
|
+
ensurePrivateDirectory(reportDirectory);
|
|
184
|
+
return true;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ensurePrivateDirectory(directoryPath: string): void {
|
|
191
|
+
fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 });
|
|
192
|
+
try {
|
|
193
|
+
fs.chmodSync(directoryPath, 0o700);
|
|
194
|
+
} catch {
|
|
195
|
+
// Windows and some mounted filesystems do not implement POSIX modes.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function rotateLogFile(filePath: string): void {
|
|
200
|
+
const size = fs.statSync(filePath, { throwIfNoEntry: false })?.size ?? 0;
|
|
201
|
+
if (size < MAX_LOG_BYTES) return;
|
|
202
|
+
for (let index = LOG_BACKUP_COUNT; index >= 1; index -= 1) {
|
|
203
|
+
const source = index === 1 ? filePath : `${filePath}.${index - 1}`;
|
|
204
|
+
const target = `${filePath}.${index}`;
|
|
205
|
+
if (!fs.existsSync(source)) continue;
|
|
206
|
+
fs.rmSync(target, { force: true });
|
|
207
|
+
fs.renameSync(source, target);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function findDiagnosticReports(
|
|
212
|
+
reportDirectory: string,
|
|
213
|
+
workerPid: number | undefined,
|
|
214
|
+
startedAtMs: number,
|
|
215
|
+
): string[] {
|
|
216
|
+
if (!fs.existsSync(reportDirectory)) return [];
|
|
217
|
+
const pidToken = workerPid === undefined ? undefined : `.${workerPid}.`;
|
|
218
|
+
return fs.readdirSync(reportDirectory, { withFileTypes: true })
|
|
219
|
+
.filter((entry) => entry.isFile() && /^report\..+\.json$/i.test(entry.name))
|
|
220
|
+
.filter((entry) => {
|
|
221
|
+
if (pidToken && entry.name.includes(pidToken)) return true;
|
|
222
|
+
const modifiedAt = fs.statSync(path.join(reportDirectory, entry.name)).mtimeMs;
|
|
223
|
+
return workerPid === undefined && modifiedAt >= startedAtMs - 1_000;
|
|
224
|
+
})
|
|
225
|
+
.map((entry) => path.join(reportDirectory, entry.name))
|
|
226
|
+
.sort();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function crashRecordFilename(exitedAt: string, workerPid: number | undefined): string {
|
|
230
|
+
const timestamp = exitedAt.replace(/\D/g, "");
|
|
231
|
+
return `crash-${timestamp}-${workerPid ?? "unknown"}.json`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function writePrivateJson(filePath: string, value: unknown): void {
|
|
235
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
236
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
237
|
+
encoding: "utf8",
|
|
238
|
+
mode: 0o600,
|
|
239
|
+
});
|
|
240
|
+
restrictFilePermissions(filePath);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function restrictFilePermissions(filePath: string): void {
|
|
244
|
+
try {
|
|
245
|
+
fs.chmodSync(filePath, 0o600);
|
|
246
|
+
} catch {
|
|
247
|
+
// Windows and some mounted filesystems do not implement POSIX modes.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function writeFallbackDiagnostic(event: string, error: unknown): void {
|
|
252
|
+
try {
|
|
253
|
+
process.stderr.write(
|
|
254
|
+
`${JSON.stringify({
|
|
255
|
+
component: "agent-bot-supervisor",
|
|
256
|
+
event,
|
|
257
|
+
time: new Date().toISOString(),
|
|
258
|
+
error: errorMessage(error),
|
|
259
|
+
})}\n`,
|
|
260
|
+
);
|
|
261
|
+
} catch {
|
|
262
|
+
// Diagnostics must never prevent the supervisor from keeping the worker alive.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function errorMessage(error: unknown): string {
|
|
267
|
+
return error instanceof Error ? error.message : String(error);
|
|
268
|
+
}
|