@keyou007/agent-bot 0.1.13-alpha.0 → 0.1.13-alpha.2
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 +2 -2
- package/CHANGELOG.md +15 -0
- package/README.md +107 -98
- package/README.zh.md +81 -72
- package/dist/acp/AcpProcessManager.js +8 -3
- package/dist/acp/AcpProcessManager.js.map +1 -1
- package/dist/acp/AcpSessionManager.js +15 -0
- package/dist/acp/AcpSessionManager.js.map +1 -1
- package/dist/cli/ServerStarter.js +17 -2
- package/dist/cli/ServerStarter.js.map +1 -1
- package/dist/cli/controlProtocol.js.map +1 -1
- package/dist/cli/help.js +16 -4
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/serverStatus.js +27 -0
- package/dist/cli/serverStatus.js.map +1 -1
- package/dist/cli/taskListOutput.js +72 -0
- package/dist/cli/taskListOutput.js.map +1 -0
- package/dist/cli.js +88 -54
- package/dist/cli.js.map +1 -1
- package/dist/codex/CodexProcessManager.js +34 -4
- package/dist/codex/CodexProcessManager.js.map +1 -1
- package/dist/codex/CodexRuntime.js +3 -0
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/commands/CommandRouter.js +5 -0
- package/dist/commands/CommandRouter.js.map +1 -1
- package/dist/config/FeishuUserOpenIdStore.js +76 -0
- package/dist/config/FeishuUserOpenIdStore.js.map +1 -0
- package/dist/config/loadConfig.js +3 -0
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/feishu/CardRenderer.js +123 -22
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +16 -5
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/index.js +125 -26
- package/dist/index.js.map +1 -1
- package/dist/presentation/turnGraph.js +36 -0
- package/dist/presentation/turnGraph.js.map +1 -0
- package/dist/proxy/ProxySessionController.js +425 -104
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/runtime/AcpRuntimeAdapter.js +3 -0
- package/dist/runtime/AcpRuntimeAdapter.js.map +1 -1
- package/dist/runtime/agentEnvironment.js +24 -4
- package/dist/runtime/agentEnvironment.js.map +1 -1
- package/dist/runtime/createAgentRuntimeRegistry.js +7 -3
- package/dist/runtime/createAgentRuntimeRegistry.js.map +1 -1
- package/dist/startup/StartupNotifier.js +16 -7
- package/dist/startup/StartupNotifier.js.map +1 -1
- package/dist/startup/startFeishu.js +2 -2
- package/dist/startup/startFeishu.js.map +1 -1
- package/dist/state/StateStore.js +172 -0
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/migrations.js +21 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/supervision/SafeRestartNotifier.js +46 -3
- package/dist/supervision/SafeRestartNotifier.js.map +1 -1
- package/dist/supervision/SafeRestartScheduler.js +65 -6
- package/dist/supervision/SafeRestartScheduler.js.map +1 -1
- package/dist/supervision/replacementSupervisor.js +27 -2
- package/dist/supervision/replacementSupervisor.js.map +1 -1
- package/dist/supervisor.js +27 -2
- package/dist/supervisor.js.map +1 -1
- package/docs/technical-reference.md +20 -7
- package/docs/technical-reference.zh.md +20 -7
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +63 -26
- package/src/acp/AcpProcessManager.ts +16 -3
- package/src/acp/AcpSessionManager.ts +14 -0
- package/src/cli/ServerStarter.ts +25 -2
- package/src/cli/controlProtocol.ts +6 -1
- package/src/cli/help.ts +16 -4
- package/src/cli/serverStatus.ts +28 -0
- package/src/cli/taskListOutput.ts +91 -0
- package/src/cli.ts +123 -51
- package/src/codex/CodexProcessManager.ts +38 -3
- package/src/codex/CodexRuntime.ts +6 -0
- package/src/commands/CommandRouter.ts +4 -0
- package/src/commands/commandTypes.ts +1 -0
- package/src/config/FeishuUserOpenIdStore.ts +81 -0
- package/src/config/loadConfig.ts +3 -0
- package/src/feishu/CardRenderer.ts +169 -22
- package/src/feishu/FeishuConnector.ts +13 -4
- package/src/index.ts +195 -23
- package/src/presentation/turnGraph.ts +48 -0
- package/src/proxy/ProxySessionController.ts +507 -112
- package/src/runtime/AcpRuntimeAdapter.ts +5 -0
- package/src/runtime/agentEnvironment.ts +41 -3
- package/src/runtime/createAgentRuntimeRegistry.ts +18 -3
- package/src/runtime/types.ts +5 -0
- package/src/startup/StartupNotifier.ts +23 -7
- package/src/startup/startFeishu.ts +3 -2
- package/src/state/StateStore.ts +224 -0
- package/src/state/migrations.ts +21 -0
- package/src/supervision/SafeRestartNotifier.ts +53 -4
- package/src/supervision/SafeRestartScheduler.ts +78 -7
- package/src/supervision/replacementSupervisor.ts +27 -1
- package/src/supervisor.ts +33 -2
|
@@ -3,6 +3,7 @@ import { AcpSessionManager } from "../acp/AcpSessionManager.js";
|
|
|
3
3
|
import { createId } from "../utils/id.js";
|
|
4
4
|
import type {
|
|
5
5
|
AgentRuntime,
|
|
6
|
+
AgentProcessInfo,
|
|
6
7
|
ApprovalDecision,
|
|
7
8
|
CreateRuntimeSessionInput,
|
|
8
9
|
ModelOption,
|
|
@@ -37,6 +38,10 @@ export class AcpRuntimeAdapter implements AgentRuntime {
|
|
|
37
38
|
return this.sessions.get(localSessionId);
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
getProcessInfo(): AgentProcessInfo {
|
|
42
|
+
return this.acp.getProcessInfo();
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
async createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession> {
|
|
41
46
|
const created = await this.acp.create({
|
|
42
47
|
localSessionId: input.localSessionId,
|
|
@@ -2,13 +2,51 @@ export const AGENT_BOT_ENVIRONMENT = Object.freeze({
|
|
|
2
2
|
AGENT_BOT: "1",
|
|
3
3
|
});
|
|
4
4
|
|
|
5
|
+
export interface AgentEnvironmentContext {
|
|
6
|
+
profilePath?: string;
|
|
7
|
+
configPath?: string;
|
|
8
|
+
agentName?: string;
|
|
9
|
+
larkAppId?: string;
|
|
10
|
+
larkBotOpenId?: string;
|
|
11
|
+
larkUserOpenId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RESERVED_ENVIRONMENT_PREFIXES = ["AGENT_BOT_", "FEISHU_"];
|
|
15
|
+
|
|
5
16
|
export function agentBotEnvironment(
|
|
6
17
|
baseEnv: NodeJS.ProcessEnv,
|
|
7
18
|
agentEnv: Record<string, string>,
|
|
19
|
+
context: AgentEnvironmentContext = {},
|
|
8
20
|
): NodeJS.ProcessEnv {
|
|
9
|
-
|
|
10
|
-
...baseEnv,
|
|
11
|
-
...agentEnv,
|
|
21
|
+
const environment: NodeJS.ProcessEnv = {
|
|
22
|
+
...withoutReservedEnvironment(baseEnv),
|
|
23
|
+
...withoutReservedEnvironment(agentEnv),
|
|
12
24
|
...AGENT_BOT_ENVIRONMENT,
|
|
13
25
|
};
|
|
26
|
+
setNonBlank(environment, "AGENT_BOT_HOME", context.profilePath);
|
|
27
|
+
setNonBlank(environment, "AGENT_BOT_CONFIG", context.configPath);
|
|
28
|
+
setNonBlank(environment, "AGENT_BOT_AGENT_NAME", context.agentName);
|
|
29
|
+
setNonBlank(environment, "AGENT_BOT_LARK_APP_ID", context.larkAppId);
|
|
30
|
+
setNonBlank(environment, "AGENT_BOT_LARK_BOT_OPEN_ID", context.larkBotOpenId);
|
|
31
|
+
setNonBlank(environment, "AGENT_BOT_LARK_USER_OPEN_ID", context.larkUserOpenId);
|
|
32
|
+
return environment;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function withoutReservedEnvironment(
|
|
36
|
+
environment: NodeJS.ProcessEnv | Record<string, string>,
|
|
37
|
+
): NodeJS.ProcessEnv {
|
|
38
|
+
return Object.fromEntries(
|
|
39
|
+
Object.entries(environment).filter(([name, value]) =>
|
|
40
|
+
value !== undefined && !isReservedEnvironmentName(name)),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isReservedEnvironmentName(name: string): boolean {
|
|
45
|
+
const normalized = name.toUpperCase();
|
|
46
|
+
return normalized === "AGENT_BOT"
|
|
47
|
+
|| RESERVED_ENVIRONMENT_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setNonBlank(environment: NodeJS.ProcessEnv, name: string, value: string | undefined): void {
|
|
51
|
+
if (value?.trim()) environment[name] = value;
|
|
14
52
|
}
|
|
@@ -5,19 +5,34 @@ import { CodexProcessManager } from "../codex/CodexProcessManager.js";
|
|
|
5
5
|
import { CodexRuntime } from "../codex/CodexRuntime.js";
|
|
6
6
|
import type { AppConfig } from "../config/schema.js";
|
|
7
7
|
import { AcpRuntimeAdapter } from "./AcpRuntimeAdapter.js";
|
|
8
|
+
import type { AgentEnvironmentContext } from "./agentEnvironment.js";
|
|
8
9
|
import { AgentRuntimeRegistry } from "./AgentRuntimeRegistry.js";
|
|
9
10
|
import type { AgentRuntime } from "./types.js";
|
|
10
11
|
|
|
11
|
-
export function createAgentRuntimeRegistry(
|
|
12
|
+
export function createAgentRuntimeRegistry(
|
|
13
|
+
config: AppConfig,
|
|
14
|
+
logger: Logger,
|
|
15
|
+
environmentContext: AgentEnvironmentContext = {},
|
|
16
|
+
): AgentRuntimeRegistry {
|
|
12
17
|
const runtimes: Record<string, AgentRuntime> = {};
|
|
13
18
|
for (const [agentName, agent] of Object.entries(config.agents)) {
|
|
14
19
|
const runtimeLogger = logger.child({ agentName, runtimeKind: agent.kind });
|
|
20
|
+
const runtimeEnvironmentContext = (): AgentEnvironmentContext => ({
|
|
21
|
+
...environmentContext,
|
|
22
|
+
agentName,
|
|
23
|
+
});
|
|
15
24
|
if (agent.kind === "app-server") {
|
|
16
|
-
const processManager = new CodexProcessManager(
|
|
25
|
+
const processManager = new CodexProcessManager(
|
|
26
|
+
agent.command,
|
|
27
|
+
agent.args,
|
|
28
|
+
agent.env,
|
|
29
|
+
runtimeLogger,
|
|
30
|
+
runtimeEnvironmentContext,
|
|
31
|
+
);
|
|
17
32
|
runtimes[agentName] = new CodexRuntime(processManager, runtimeLogger);
|
|
18
33
|
continue;
|
|
19
34
|
}
|
|
20
|
-
const processManager = new AcpProcessManager(runtimeLogger);
|
|
35
|
+
const processManager = new AcpProcessManager(runtimeLogger, runtimeEnvironmentContext);
|
|
21
36
|
const sessionManager = new AcpSessionManager(agentName, agent, processManager, runtimeLogger);
|
|
22
37
|
runtimes[agentName] = new AcpRuntimeAdapter(sessionManager);
|
|
23
38
|
}
|
package/src/runtime/types.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export type RuntimeKind = "acp" | "codex";
|
|
2
|
+
export interface AgentProcessInfo {
|
|
3
|
+
pid?: number;
|
|
4
|
+
version?: string;
|
|
5
|
+
}
|
|
2
6
|
export type PermissionMode = "auto" | "confirm";
|
|
3
7
|
export type ApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel";
|
|
4
8
|
export type ToolStatus = "running" | "completed" | "failed";
|
|
@@ -203,6 +207,7 @@ export interface ReasoningEffortOption {
|
|
|
203
207
|
|
|
204
208
|
export interface AgentRuntime {
|
|
205
209
|
readonly kind: RuntimeKind;
|
|
210
|
+
getProcessInfo?(): AgentProcessInfo;
|
|
206
211
|
createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession>;
|
|
207
212
|
resumeSession(input: ResumeRuntimeSessionInput): Promise<RuntimeSession>;
|
|
208
213
|
forkSession?(input: ForkRuntimeSessionInput): Promise<RuntimeSession>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import { detectProjectlessWorkspace } from "../codex/ProjectlessWorkspace.js";
|
|
3
3
|
import { CardRenderer } from "../feishu/CardRenderer.js";
|
|
4
|
+
import { baseChatContextKey, isThreadContextKey } from "../feishu/contextKey.js";
|
|
4
5
|
import type { FeishuOutbound } from "../feishu/types.js";
|
|
5
6
|
import type { SessionRecord, StateStore, UserContextRecord } from "../state/StateStore.js";
|
|
6
7
|
|
|
@@ -32,15 +33,30 @@ export class StartupNotifier {
|
|
|
32
33
|
private readonly metadataHydrator?: StartupTaskMetadataHydrator,
|
|
33
34
|
) {}
|
|
34
35
|
|
|
35
|
-
async notify(
|
|
36
|
+
async notify(
|
|
37
|
+
startedAt: Date,
|
|
38
|
+
restartReason: string,
|
|
39
|
+
restartGroupContextKeys: string[] = [],
|
|
40
|
+
): Promise<void> {
|
|
36
41
|
let targets: StartupNotificationTarget[];
|
|
37
42
|
try {
|
|
38
|
-
const activeGroupSince = new Date(startedAt.getTime() -
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
.
|
|
43
|
-
|
|
43
|
+
const activeGroupSince = new Date(startedAt.getTime() - 60_000);
|
|
44
|
+
const knownGroups = new Map(
|
|
45
|
+
this.store.listChatContexts("group")
|
|
46
|
+
.filter((chat) => !isThreadContextKey(chat.contextKey))
|
|
47
|
+
.map((chat) => [chat.contextKey, chat]),
|
|
48
|
+
);
|
|
49
|
+
const targetGroups = new Map(
|
|
50
|
+
this.store.listRecentlyActiveChatContexts(activeGroupSince)
|
|
51
|
+
.filter((chat) => chat.chatType === "group" && !isThreadContextKey(chat.contextKey))
|
|
52
|
+
.map((chat) => [chat.contextKey, chat]),
|
|
53
|
+
);
|
|
54
|
+
for (const contextKey of restartGroupContextKeys) {
|
|
55
|
+
const baseContextKey = baseChatContextKey(contextKey.trim());
|
|
56
|
+
const group = knownGroups.get(baseContextKey);
|
|
57
|
+
if (group) targetGroups.set(baseContextKey, group);
|
|
58
|
+
}
|
|
59
|
+
const chats = [...this.store.listChatContexts("p2p"), ...targetGroups.values()];
|
|
44
60
|
targets = chats.map((chat) => ({
|
|
45
61
|
contextKey: chat.contextKey,
|
|
46
62
|
context: this.store.getUserContext(chat.contextKey),
|
|
@@ -3,7 +3,7 @@ export interface StartableFeishuConnector {
|
|
|
3
3
|
}
|
|
4
4
|
|
|
5
5
|
export interface StartupNotificationSender {
|
|
6
|
-
notify(startedAt: Date, restartReason: string): Promise<void>;
|
|
6
|
+
notify(startedAt: Date, restartReason: string, restartGroupContextKeys?: string[]): Promise<void>;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export async function startFeishu(
|
|
@@ -13,9 +13,10 @@ export async function startFeishu(
|
|
|
13
13
|
restartReason: string,
|
|
14
14
|
prepare?: () => Promise<void>,
|
|
15
15
|
onConnected?: () => void,
|
|
16
|
+
restartGroupContextKeys: string[] = [],
|
|
16
17
|
): Promise<void> {
|
|
17
18
|
await prepare?.();
|
|
18
19
|
await connector.start();
|
|
19
|
-
await notifier.notify(startedAt, restartReason);
|
|
20
|
+
await notifier.notify(startedAt, restartReason, restartGroupContextKeys);
|
|
20
21
|
onConnected?.();
|
|
21
22
|
}
|
package/src/state/StateStore.ts
CHANGED
|
@@ -63,6 +63,21 @@ export interface TurnAnchorRecord {
|
|
|
63
63
|
contextKey?: string;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
export interface TurnRuntimeOriginRecord {
|
|
67
|
+
turnId: string;
|
|
68
|
+
localSessionId: string;
|
|
69
|
+
agentName: string;
|
|
70
|
+
remoteSessionId: string;
|
|
71
|
+
createdAt: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CompletedTurnSnapshotRecord {
|
|
75
|
+
turnId: string;
|
|
76
|
+
parentTurnId?: string;
|
|
77
|
+
snapshot: unknown;
|
|
78
|
+
updatedAt: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
export interface QueuedPromptRecord {
|
|
67
82
|
promptId: string;
|
|
68
83
|
localSessionId: string;
|
|
@@ -605,6 +620,54 @@ export class StateStore {
|
|
|
605
620
|
return row ? JSON.parse(row.snapshot_json) : undefined;
|
|
606
621
|
}
|
|
607
622
|
|
|
623
|
+
saveTurnRuntimeOrigin(
|
|
624
|
+
turnId: string,
|
|
625
|
+
localSessionId: string,
|
|
626
|
+
agentName: string,
|
|
627
|
+
remoteSessionId: string,
|
|
628
|
+
): void {
|
|
629
|
+
this.db.prepare(`
|
|
630
|
+
INSERT OR IGNORE INTO turn_runtime_origins (
|
|
631
|
+
turn_id, local_session_id, agent_name, remote_session_id, created_at
|
|
632
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
633
|
+
`).run(turnId, localSessionId, agentName, remoteSessionId, new Date().toISOString());
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
getTurnRuntimeOrigin(turnId: string): TurnRuntimeOriginRecord | undefined {
|
|
637
|
+
const row = this.db.prepare(`
|
|
638
|
+
SELECT turn_id, local_session_id, agent_name, remote_session_id, created_at
|
|
639
|
+
FROM turn_runtime_origins
|
|
640
|
+
WHERE turn_id = ?
|
|
641
|
+
`).get(turnId) as {
|
|
642
|
+
turn_id: string;
|
|
643
|
+
local_session_id: string;
|
|
644
|
+
agent_name: string;
|
|
645
|
+
remote_session_id: string;
|
|
646
|
+
created_at: string;
|
|
647
|
+
} | undefined;
|
|
648
|
+
return row
|
|
649
|
+
? {
|
|
650
|
+
turnId: row.turn_id,
|
|
651
|
+
localSessionId: row.local_session_id,
|
|
652
|
+
agentName: row.agent_name,
|
|
653
|
+
remoteSessionId: row.remote_session_id,
|
|
654
|
+
createdAt: row.created_at,
|
|
655
|
+
}
|
|
656
|
+
: undefined;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
saveTurnParent(turnId: string, localSessionId: string, parentTurnId?: string): void {
|
|
660
|
+
this.db.prepare(`
|
|
661
|
+
INSERT INTO turn_parent_links (
|
|
662
|
+
turn_id, local_session_id, parent_turn_id, created_at
|
|
663
|
+
) VALUES (?, ?, ?, ?)
|
|
664
|
+
ON CONFLICT(turn_id) DO UPDATE SET
|
|
665
|
+
parent_turn_id = excluded.parent_turn_id
|
|
666
|
+
WHERE turn_parent_links.parent_turn_id IS NULL
|
|
667
|
+
AND excluded.parent_turn_id IS NOT NULL
|
|
668
|
+
`).run(turnId, localSessionId, parentTurnId ?? null, new Date().toISOString());
|
|
669
|
+
}
|
|
670
|
+
|
|
608
671
|
findLatestCompletedTurnId(localSessionId: string, contextKey: string): string | undefined {
|
|
609
672
|
const row = this.db
|
|
610
673
|
.prepare(`
|
|
@@ -626,6 +689,142 @@ export class StateStore {
|
|
|
626
689
|
return row?.turn_id;
|
|
627
690
|
}
|
|
628
691
|
|
|
692
|
+
countCompletedTurnSnapshots(localSessionId: string, contextKey: string): number {
|
|
693
|
+
const row = this.db.prepare(`
|
|
694
|
+
SELECT count(*) AS count
|
|
695
|
+
FROM turn_snapshots
|
|
696
|
+
WHERE local_session_id = ?
|
|
697
|
+
AND context_key = ?
|
|
698
|
+
AND json_extract(snapshot_json, '$.status') = 'completed'
|
|
699
|
+
`).get(localSessionId, contextKey) as { count: number };
|
|
700
|
+
return row.count;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
listCompletedTurnSnapshots(
|
|
704
|
+
localSessionId: string,
|
|
705
|
+
contextKey: string,
|
|
706
|
+
limit: number,
|
|
707
|
+
offset = 0,
|
|
708
|
+
): CompletedTurnSnapshotRecord[] {
|
|
709
|
+
const normalizedLimit = Math.max(1, Math.min(100, Math.trunc(limit)));
|
|
710
|
+
const normalizedOffset = Math.max(0, Math.trunc(offset));
|
|
711
|
+
const rows = this.db.prepare(`
|
|
712
|
+
SELECT turn_id, snapshot_json, updated_at
|
|
713
|
+
FROM turn_snapshots
|
|
714
|
+
WHERE local_session_id = ?
|
|
715
|
+
AND context_key = ?
|
|
716
|
+
AND json_extract(snapshot_json, '$.status') = 'completed'
|
|
717
|
+
ORDER BY
|
|
718
|
+
coalesce(
|
|
719
|
+
json_extract(snapshot_json, '$.completedAt'),
|
|
720
|
+
json_extract(snapshot_json, '$.startedAt'),
|
|
721
|
+
0
|
|
722
|
+
) DESC,
|
|
723
|
+
updated_at DESC
|
|
724
|
+
LIMIT ? OFFSET ?
|
|
725
|
+
`).all(localSessionId, contextKey, normalizedLimit, normalizedOffset) as Array<{
|
|
726
|
+
turn_id: string;
|
|
727
|
+
snapshot_json: string;
|
|
728
|
+
updated_at: string;
|
|
729
|
+
}>;
|
|
730
|
+
return rows.map((row) => ({
|
|
731
|
+
turnId: row.turn_id,
|
|
732
|
+
snapshot: JSON.parse(row.snapshot_json),
|
|
733
|
+
updatedAt: row.updated_at,
|
|
734
|
+
}));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
listCompletedTurnGraph(
|
|
738
|
+
localSessionId: string,
|
|
739
|
+
contextKey: string,
|
|
740
|
+
): CompletedTurnSnapshotRecord[] {
|
|
741
|
+
this.backfillTurnParents(localSessionId);
|
|
742
|
+
const rows = this.db.prepare(`
|
|
743
|
+
SELECT ts.turn_id, tpl.parent_turn_id, ts.snapshot_json, ts.updated_at
|
|
744
|
+
FROM turn_snapshots ts
|
|
745
|
+
LEFT JOIN turn_parent_links tpl ON tpl.turn_id = ts.turn_id
|
|
746
|
+
WHERE ts.local_session_id = ?
|
|
747
|
+
AND ts.context_key = ?
|
|
748
|
+
AND json_extract(ts.snapshot_json, '$.status') = 'completed'
|
|
749
|
+
ORDER BY
|
|
750
|
+
coalesce(
|
|
751
|
+
json_extract(ts.snapshot_json, '$.completedAt'),
|
|
752
|
+
json_extract(ts.snapshot_json, '$.startedAt'),
|
|
753
|
+
0
|
|
754
|
+
) DESC,
|
|
755
|
+
ts.updated_at DESC
|
|
756
|
+
`).all(localSessionId, contextKey) as Array<{
|
|
757
|
+
turn_id: string;
|
|
758
|
+
parent_turn_id: string | null;
|
|
759
|
+
snapshot_json: string;
|
|
760
|
+
updated_at: string;
|
|
761
|
+
}>;
|
|
762
|
+
return rows.map((row) => ({
|
|
763
|
+
turnId: row.turn_id,
|
|
764
|
+
parentTurnId: row.parent_turn_id ?? undefined,
|
|
765
|
+
snapshot: JSON.parse(row.snapshot_json),
|
|
766
|
+
updatedAt: row.updated_at,
|
|
767
|
+
}));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private backfillTurnParents(localSessionId: string): void {
|
|
771
|
+
const turns = this.db.prepare(`
|
|
772
|
+
SELECT
|
|
773
|
+
turn_id,
|
|
774
|
+
coalesce(
|
|
775
|
+
json_extract(snapshot_json, '$.startedAt'),
|
|
776
|
+
json_extract(snapshot_json, '$.completedAt'),
|
|
777
|
+
cast(strftime('%s', updated_at) AS INTEGER) * 1000,
|
|
778
|
+
0
|
|
779
|
+
) AS started_at
|
|
780
|
+
FROM turn_snapshots
|
|
781
|
+
WHERE local_session_id = ?
|
|
782
|
+
AND json_extract(snapshot_json, '$.status') = 'completed'
|
|
783
|
+
ORDER BY started_at ASC, updated_at ASC
|
|
784
|
+
`).all(localSessionId) as Array<{ turn_id: string; started_at: number }>;
|
|
785
|
+
if (turns.length === 0) return;
|
|
786
|
+
|
|
787
|
+
const resets = (this.db.prepare(`
|
|
788
|
+
SELECT payload_json, created_at
|
|
789
|
+
FROM audit_events
|
|
790
|
+
WHERE event_type = 'session_reset_to_turn'
|
|
791
|
+
AND json_extract(payload_json, '$.localSessionId') = ?
|
|
792
|
+
ORDER BY created_at ASC, id ASC
|
|
793
|
+
`).all(localSessionId) as Array<{ payload_json: string; created_at: string }>).flatMap((row) => {
|
|
794
|
+
try {
|
|
795
|
+
const payload = JSON.parse(row.payload_json) as { resetTurnId?: unknown };
|
|
796
|
+
const resetTurnId = typeof payload.resetTurnId === "string" ? payload.resetTurnId : undefined;
|
|
797
|
+
const at = Date.parse(row.created_at);
|
|
798
|
+
return resetTurnId && Number.isFinite(at) ? [{ resetTurnId, at }] : [];
|
|
799
|
+
} catch {
|
|
800
|
+
return [];
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
let headTurnId: string | undefined;
|
|
805
|
+
let resetIndex = 0;
|
|
806
|
+
const upsert = this.db.prepare(`
|
|
807
|
+
INSERT INTO turn_parent_links (
|
|
808
|
+
turn_id, local_session_id, parent_turn_id, created_at
|
|
809
|
+
) VALUES (?, ?, ?, ?)
|
|
810
|
+
ON CONFLICT(turn_id) DO UPDATE SET
|
|
811
|
+
parent_turn_id = excluded.parent_turn_id
|
|
812
|
+
WHERE turn_parent_links.parent_turn_id IS NULL
|
|
813
|
+
AND excluded.parent_turn_id IS NOT NULL
|
|
814
|
+
`);
|
|
815
|
+
const backfill = this.db.transaction(() => {
|
|
816
|
+
for (const turn of turns) {
|
|
817
|
+
while (resetIndex < resets.length && resets[resetIndex]!.at <= turn.started_at) {
|
|
818
|
+
headTurnId = resets[resetIndex]!.resetTurnId;
|
|
819
|
+
resetIndex += 1;
|
|
820
|
+
}
|
|
821
|
+
upsert.run(turn.turn_id, localSessionId, headTurnId ?? null, new Date().toISOString());
|
|
822
|
+
headTurnId = turn.turn_id;
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
backfill();
|
|
826
|
+
}
|
|
827
|
+
|
|
629
828
|
saveTurnDelivery(
|
|
630
829
|
turnId: string,
|
|
631
830
|
patch: { progressMessageId?: string; lastCardHash?: string },
|
|
@@ -784,6 +983,31 @@ export class StateStore {
|
|
|
784
983
|
return rows.map(mapSession);
|
|
785
984
|
}
|
|
786
985
|
|
|
986
|
+
findLatestMessageIdForSession(localSessionId: string, contextKey?: string): string | undefined {
|
|
987
|
+
const row = this.db.prepare(`
|
|
988
|
+
SELECT message_id
|
|
989
|
+
FROM message_reactions
|
|
990
|
+
WHERE local_session_id = ?
|
|
991
|
+
AND (? IS NULL OR context_key = ?)
|
|
992
|
+
ORDER BY created_at DESC, rowid DESC
|
|
993
|
+
LIMIT 1
|
|
994
|
+
`).get(localSessionId, contextKey ?? null, contextKey ?? null) as { message_id: string } | undefined;
|
|
995
|
+
return row?.message_id;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
findLatestMessageIdForContext(contextKey: string): string | undefined {
|
|
999
|
+
const row = this.db.prepare(`
|
|
1000
|
+
SELECT json_extract(payload_json, '$.messageId') AS message_id
|
|
1001
|
+
FROM audit_events
|
|
1002
|
+
WHERE context_key = ?
|
|
1003
|
+
AND event_type = 'incoming_message'
|
|
1004
|
+
AND json_type(payload_json, '$.messageId') = 'text'
|
|
1005
|
+
ORDER BY id DESC
|
|
1006
|
+
LIMIT 1
|
|
1007
|
+
`).get(contextKey) as { message_id: string } | undefined;
|
|
1008
|
+
return row?.message_id;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
787
1011
|
getServerActivityState(): {
|
|
788
1012
|
runningSessions: number;
|
|
789
1013
|
pendingFinalDeliveries: number;
|
package/src/state/migrations.ts
CHANGED
|
@@ -125,4 +125,25 @@ export const migrations = [
|
|
|
125
125
|
CREATE INDEX IF NOT EXISTS idx_queued_prompts_session_order
|
|
126
126
|
ON queued_prompts(local_session_id, created_at);
|
|
127
127
|
`,
|
|
128
|
+
`
|
|
129
|
+
CREATE TABLE IF NOT EXISTS turn_runtime_origins (
|
|
130
|
+
turn_id TEXT PRIMARY KEY,
|
|
131
|
+
local_session_id TEXT NOT NULL,
|
|
132
|
+
agent_name TEXT NOT NULL,
|
|
133
|
+
remote_session_id TEXT NOT NULL,
|
|
134
|
+
created_at TEXT NOT NULL
|
|
135
|
+
);
|
|
136
|
+
CREATE INDEX IF NOT EXISTS idx_turn_runtime_origins_session
|
|
137
|
+
ON turn_runtime_origins(local_session_id);
|
|
138
|
+
`,
|
|
139
|
+
`
|
|
140
|
+
CREATE TABLE IF NOT EXISTS turn_parent_links (
|
|
141
|
+
turn_id TEXT PRIMARY KEY,
|
|
142
|
+
local_session_id TEXT NOT NULL,
|
|
143
|
+
parent_turn_id TEXT,
|
|
144
|
+
created_at TEXT NOT NULL
|
|
145
|
+
);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_turn_parent_links_session
|
|
147
|
+
ON turn_parent_links(local_session_id);
|
|
148
|
+
`,
|
|
128
149
|
] as const;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import { CardRenderer } from "../feishu/CardRenderer.js";
|
|
3
|
+
import { isThreadContextKey } from "../feishu/contextKey.js";
|
|
3
4
|
import type { FeishuOutbound } from "../feishu/types.js";
|
|
4
5
|
import type { StateStore } from "../state/StateStore.js";
|
|
5
|
-
import type { SafeRestartStatus } from "./SafeRestartScheduler.js";
|
|
6
|
+
import type { RestartNotificationTarget, SafeRestartStatus } from "./SafeRestartScheduler.js";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_INITIAL_CARD_DELAY_MS = 3_000;
|
|
8
9
|
|
|
@@ -13,6 +14,7 @@ export interface SafeRestartNotifierOptions {
|
|
|
13
14
|
export class SafeRestartNotifier {
|
|
14
15
|
private readonly messageIds = new Map<string, string>();
|
|
15
16
|
private readonly cardHashes = new Map<string, string>();
|
|
17
|
+
private readonly notificationTargets = new Map<string, RestartNotificationTarget>();
|
|
16
18
|
private activeScheduleId?: number;
|
|
17
19
|
private terminalScheduleId?: number;
|
|
18
20
|
private pendingInitialStatus?: SafeRestartStatus;
|
|
@@ -41,8 +43,10 @@ export class SafeRestartNotifier {
|
|
|
41
43
|
this.activeScheduleId = status.scheduleId;
|
|
42
44
|
this.messageIds.clear();
|
|
43
45
|
this.cardHashes.clear();
|
|
46
|
+
this.notificationTargets.clear();
|
|
44
47
|
this.clearInitialCardTimer();
|
|
45
48
|
this.pendingInitialStatus = status;
|
|
49
|
+
this.collectNotificationTargets(status);
|
|
46
50
|
const delayMs = status.phase === "cancelled"
|
|
47
51
|
? 0
|
|
48
52
|
: this.options.initialCardDelayMs ?? DEFAULT_INITIAL_CARD_DELAY_MS;
|
|
@@ -55,6 +59,7 @@ export class SafeRestartNotifier {
|
|
|
55
59
|
}, delayMs);
|
|
56
60
|
return Promise.resolve();
|
|
57
61
|
}
|
|
62
|
+
this.collectNotificationTargets(status);
|
|
58
63
|
if (this.pendingInitialStatus) {
|
|
59
64
|
this.pendingInitialStatus = status;
|
|
60
65
|
return Promise.resolve();
|
|
@@ -67,6 +72,10 @@ export class SafeRestartNotifier {
|
|
|
67
72
|
await this.queue;
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
getNotificationTargets(): RestartNotificationTarget[] {
|
|
76
|
+
return [...this.notificationTargets.values()].map((target) => ({ ...target }));
|
|
77
|
+
}
|
|
78
|
+
|
|
70
79
|
private publishPendingInitialStatus(): Promise<void> {
|
|
71
80
|
this.clearInitialCardTimer();
|
|
72
81
|
const status = this.pendingInitialStatus;
|
|
@@ -89,7 +98,8 @@ export class SafeRestartNotifier {
|
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
private async publish(status: SafeRestartStatus): Promise<void> {
|
|
92
|
-
|
|
101
|
+
this.collectNotificationTargets(status);
|
|
102
|
+
const targets = this.getNotificationTargets();
|
|
93
103
|
if (targets.length === 0) return;
|
|
94
104
|
const waitingTasks = status.phase === "cancelled"
|
|
95
105
|
? []
|
|
@@ -109,7 +119,7 @@ export class SafeRestartNotifier {
|
|
|
109
119
|
});
|
|
110
120
|
const cardHash = JSON.stringify(card);
|
|
111
121
|
|
|
112
|
-
await Promise.all(targets.map(async ({ contextKey }) => {
|
|
122
|
+
await Promise.all(targets.map(async ({ contextKey, replyMessageId }) => {
|
|
113
123
|
const messageId = this.messageIds.get(contextKey);
|
|
114
124
|
if (messageId) {
|
|
115
125
|
if (this.cardHashes.get(contextKey) === cardHash) return;
|
|
@@ -124,7 +134,22 @@ export class SafeRestartNotifier {
|
|
|
124
134
|
}
|
|
125
135
|
}
|
|
126
136
|
try {
|
|
127
|
-
|
|
137
|
+
let created: string | undefined;
|
|
138
|
+
if (isThreadContextKey(contextKey) && !replyMessageId) {
|
|
139
|
+
throw new Error("The safe-restart topic target has no reply message anchor.");
|
|
140
|
+
}
|
|
141
|
+
if (replyMessageId) {
|
|
142
|
+
if (!this.outbound.replyInteractiveCard) {
|
|
143
|
+
throw new Error("The outbound transport cannot preserve the requesting thread.");
|
|
144
|
+
}
|
|
145
|
+
created = await this.outbound.replyInteractiveCard(
|
|
146
|
+
contextKey,
|
|
147
|
+
{ messageId: replyMessageId, replyInThread: true },
|
|
148
|
+
card,
|
|
149
|
+
);
|
|
150
|
+
} else {
|
|
151
|
+
created = await this.outbound.sendInteractiveCard(contextKey, card);
|
|
152
|
+
}
|
|
128
153
|
if (created && this.activeScheduleId === status.scheduleId) {
|
|
129
154
|
this.messageIds.set(contextKey, created);
|
|
130
155
|
this.cardHashes.set(contextKey, cardHash);
|
|
@@ -134,4 +159,28 @@ export class SafeRestartNotifier {
|
|
|
134
159
|
}
|
|
135
160
|
}));
|
|
136
161
|
}
|
|
162
|
+
|
|
163
|
+
private collectNotificationTargets(status: SafeRestartStatus): void {
|
|
164
|
+
const requestedTargets: RestartNotificationTarget[] = status.notificationTargets
|
|
165
|
+
?? this.store.listChatContexts("p2p").map(({ contextKey }) => ({ contextKey }));
|
|
166
|
+
const recentTargets: RestartNotificationTarget[] = this.store.listRecentlyActiveChatContexts(
|
|
167
|
+
new Date(Date.now() - 60_000),
|
|
168
|
+
)
|
|
169
|
+
.filter((chat) => !isThreadContextKey(chat.contextKey))
|
|
170
|
+
.map(({ contextKey }) => ({ contextKey }));
|
|
171
|
+
for (const target of [
|
|
172
|
+
...requestedTargets,
|
|
173
|
+
...recentTargets,
|
|
174
|
+
]) {
|
|
175
|
+
const contextKey = target.contextKey.trim();
|
|
176
|
+
if (!contextKey) continue;
|
|
177
|
+
const existing = this.notificationTargets.get(contextKey);
|
|
178
|
+
if (!existing || (!existing.replyMessageId && target.replyMessageId)) {
|
|
179
|
+
this.notificationTargets.set(contextKey, {
|
|
180
|
+
contextKey,
|
|
181
|
+
...(target.replyMessageId?.trim() ? { replyMessageId: target.replyMessageId.trim() } : {}),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
137
186
|
}
|