@keyou007/agent-bot 0.1.12 → 0.1.13-alpha.1
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 +18 -0
- package/README.md +32 -13
- package/README.zh.md +32 -13
- package/config.example.yaml +0 -24
- package/dist/cli/AgentPrerequisites.js +262 -0
- package/dist/cli/AgentPrerequisites.js.map +1 -0
- package/dist/cli/DefaultAgentSelection.js +47 -0
- package/dist/cli/DefaultAgentSelection.js.map +1 -0
- package/dist/cli/Initializer.js +165 -2
- package/dist/cli/Initializer.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 -6
- package/dist/cli/help.js.map +1 -1
- package/dist/cli.js +233 -4
- package/dist/cli.js.map +1 -1
- package/dist/codex/AppServerConnection.js +3 -3
- package/dist/codex/AppServerConnection.js.map +1 -1
- package/dist/codex/CodexProcessManager.js +4 -4
- package/dist/codex/CodexProcessManager.js.map +1 -1
- package/dist/codex/CodexRuntime.js +28 -16
- 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 -24
- package/dist/config/loadConfig.js.map +1 -1
- package/dist/console/ConsoleConnector.js +2 -2
- package/dist/console/ConsoleConnector.js.map +1 -1
- package/dist/feishu/CardRenderer.js +90 -30
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/index.js +104 -24
- 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 +321 -98
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/runtime/AcpRuntimeAdapter.js +3 -2
- package/dist/runtime/AcpRuntimeAdapter.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 +169 -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 +42 -20
- package/docs/technical-reference.zh.md +42 -20
- package/npm-shrinkwrap.json +2 -2
- package/package.json +7 -3
- package/skills/agent-bot/SKILL.md +21 -9
- package/src/cli/AgentPrerequisites.ts +377 -0
- package/src/cli/DefaultAgentSelection.ts +56 -0
- package/src/cli/Initializer.ts +204 -3
- package/src/cli/ServerStarter.ts +25 -2
- package/src/cli/controlProtocol.ts +6 -1
- package/src/cli/help.ts +16 -6
- package/src/cli.ts +350 -5
- package/src/codex/AppServerConnection.ts +3 -3
- package/src/codex/CodexProcessManager.ts +4 -4
- package/src/codex/CodexRuntime.ts +38 -18
- 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 -24
- package/src/console/ConsoleConnector.ts +2 -2
- package/src/feishu/CardRenderer.ts +117 -30
- package/src/index.ts +162 -22
- package/src/presentation/turnGraph.ts +48 -0
- package/src/proxy/ProxySessionController.ts +375 -107
- package/src/runtime/AcpRuntimeAdapter.ts +3 -1
- package/src/startup/StartupNotifier.ts +23 -7
- package/src/startup/startFeishu.ts +3 -2
- package/src/state/StateStore.ts +221 -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
|
@@ -99,7 +99,9 @@ export class AcpRuntimeAdapter implements AgentRuntime {
|
|
|
99
99
|
|
|
100
100
|
async startTurn(sessionId: string, prompt: RuntimePrompt): Promise<string> {
|
|
101
101
|
const { text, localImagePaths } = normalizeRuntimePrompt(prompt);
|
|
102
|
-
if (localImagePaths.length > 0)
|
|
102
|
+
if (localImagePaths.length > 0) {
|
|
103
|
+
throw new Error("当前 ACP Agent 不支持图片输入,请切换到支持图片输入的 App Server Agent 后重试。");
|
|
104
|
+
}
|
|
103
105
|
const session = this.requireSession(sessionId);
|
|
104
106
|
const turnId = createId("turn");
|
|
105
107
|
session.activeTurnId = turnId;
|
|
@@ -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,50 @@ 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 OR IGNORE INTO turn_parent_links (
|
|
662
|
+
turn_id, local_session_id, parent_turn_id, created_at
|
|
663
|
+
) VALUES (?, ?, ?, ?)
|
|
664
|
+
`).run(turnId, localSessionId, parentTurnId ?? null, new Date().toISOString());
|
|
665
|
+
}
|
|
666
|
+
|
|
608
667
|
findLatestCompletedTurnId(localSessionId: string, contextKey: string): string | undefined {
|
|
609
668
|
const row = this.db
|
|
610
669
|
.prepare(`
|
|
@@ -626,6 +685,143 @@ export class StateStore {
|
|
|
626
685
|
return row?.turn_id;
|
|
627
686
|
}
|
|
628
687
|
|
|
688
|
+
countCompletedTurnSnapshots(localSessionId: string, contextKey: string): number {
|
|
689
|
+
const row = this.db.prepare(`
|
|
690
|
+
SELECT count(*) AS count
|
|
691
|
+
FROM turn_snapshots
|
|
692
|
+
WHERE local_session_id = ?
|
|
693
|
+
AND context_key = ?
|
|
694
|
+
AND json_extract(snapshot_json, '$.status') = 'completed'
|
|
695
|
+
`).get(localSessionId, contextKey) as { count: number };
|
|
696
|
+
return row.count;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
listCompletedTurnSnapshots(
|
|
700
|
+
localSessionId: string,
|
|
701
|
+
contextKey: string,
|
|
702
|
+
limit: number,
|
|
703
|
+
offset = 0,
|
|
704
|
+
): CompletedTurnSnapshotRecord[] {
|
|
705
|
+
const normalizedLimit = Math.max(1, Math.min(100, Math.trunc(limit)));
|
|
706
|
+
const normalizedOffset = Math.max(0, Math.trunc(offset));
|
|
707
|
+
const rows = this.db.prepare(`
|
|
708
|
+
SELECT turn_id, snapshot_json, updated_at
|
|
709
|
+
FROM turn_snapshots
|
|
710
|
+
WHERE local_session_id = ?
|
|
711
|
+
AND context_key = ?
|
|
712
|
+
AND json_extract(snapshot_json, '$.status') = 'completed'
|
|
713
|
+
ORDER BY
|
|
714
|
+
coalesce(
|
|
715
|
+
json_extract(snapshot_json, '$.completedAt'),
|
|
716
|
+
json_extract(snapshot_json, '$.startedAt'),
|
|
717
|
+
0
|
|
718
|
+
) DESC,
|
|
719
|
+
updated_at DESC
|
|
720
|
+
LIMIT ? OFFSET ?
|
|
721
|
+
`).all(localSessionId, contextKey, normalizedLimit, normalizedOffset) as Array<{
|
|
722
|
+
turn_id: string;
|
|
723
|
+
snapshot_json: string;
|
|
724
|
+
updated_at: string;
|
|
725
|
+
}>;
|
|
726
|
+
return rows.map((row) => ({
|
|
727
|
+
turnId: row.turn_id,
|
|
728
|
+
snapshot: JSON.parse(row.snapshot_json),
|
|
729
|
+
updatedAt: row.updated_at,
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
listCompletedTurnGraph(
|
|
734
|
+
localSessionId: string,
|
|
735
|
+
contextKey: string,
|
|
736
|
+
): CompletedTurnSnapshotRecord[] {
|
|
737
|
+
this.backfillTurnParents(localSessionId);
|
|
738
|
+
const rows = this.db.prepare(`
|
|
739
|
+
SELECT ts.turn_id, tpl.parent_turn_id, ts.snapshot_json, ts.updated_at
|
|
740
|
+
FROM turn_snapshots ts
|
|
741
|
+
LEFT JOIN turn_parent_links tpl ON tpl.turn_id = ts.turn_id
|
|
742
|
+
WHERE ts.local_session_id = ?
|
|
743
|
+
AND ts.context_key = ?
|
|
744
|
+
AND json_extract(ts.snapshot_json, '$.status') = 'completed'
|
|
745
|
+
ORDER BY
|
|
746
|
+
coalesce(
|
|
747
|
+
json_extract(ts.snapshot_json, '$.completedAt'),
|
|
748
|
+
json_extract(ts.snapshot_json, '$.startedAt'),
|
|
749
|
+
0
|
|
750
|
+
) DESC,
|
|
751
|
+
ts.updated_at DESC
|
|
752
|
+
`).all(localSessionId, contextKey) as Array<{
|
|
753
|
+
turn_id: string;
|
|
754
|
+
parent_turn_id: string | null;
|
|
755
|
+
snapshot_json: string;
|
|
756
|
+
updated_at: string;
|
|
757
|
+
}>;
|
|
758
|
+
return rows.map((row) => ({
|
|
759
|
+
turnId: row.turn_id,
|
|
760
|
+
parentTurnId: row.parent_turn_id ?? undefined,
|
|
761
|
+
snapshot: JSON.parse(row.snapshot_json),
|
|
762
|
+
updatedAt: row.updated_at,
|
|
763
|
+
}));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private backfillTurnParents(localSessionId: string): void {
|
|
767
|
+
const turns = this.db.prepare(`
|
|
768
|
+
SELECT
|
|
769
|
+
turn_id,
|
|
770
|
+
coalesce(
|
|
771
|
+
json_extract(snapshot_json, '$.startedAt'),
|
|
772
|
+
json_extract(snapshot_json, '$.completedAt'),
|
|
773
|
+
cast(strftime('%s', updated_at) AS INTEGER) * 1000,
|
|
774
|
+
0
|
|
775
|
+
) AS started_at
|
|
776
|
+
FROM turn_snapshots
|
|
777
|
+
WHERE local_session_id = ?
|
|
778
|
+
AND json_extract(snapshot_json, '$.status') = 'completed'
|
|
779
|
+
ORDER BY started_at ASC, updated_at ASC
|
|
780
|
+
`).all(localSessionId) as Array<{ turn_id: string; started_at: number }>;
|
|
781
|
+
if (turns.length === 0) return;
|
|
782
|
+
|
|
783
|
+
const existing = new Set((this.db.prepare(`
|
|
784
|
+
SELECT turn_id FROM turn_parent_links WHERE local_session_id = ?
|
|
785
|
+
`).all(localSessionId) as Array<{ turn_id: string }>).map((row) => row.turn_id));
|
|
786
|
+
const resets = (this.db.prepare(`
|
|
787
|
+
SELECT payload_json, created_at
|
|
788
|
+
FROM audit_events
|
|
789
|
+
WHERE event_type = 'session_reset_to_turn'
|
|
790
|
+
AND json_extract(payload_json, '$.localSessionId') = ?
|
|
791
|
+
ORDER BY created_at ASC, id ASC
|
|
792
|
+
`).all(localSessionId) as Array<{ payload_json: string; created_at: string }>).flatMap((row) => {
|
|
793
|
+
try {
|
|
794
|
+
const payload = JSON.parse(row.payload_json) as { resetTurnId?: unknown };
|
|
795
|
+
const resetTurnId = typeof payload.resetTurnId === "string" ? payload.resetTurnId : undefined;
|
|
796
|
+
const at = Date.parse(row.created_at);
|
|
797
|
+
return resetTurnId && Number.isFinite(at) ? [{ resetTurnId, at }] : [];
|
|
798
|
+
} catch {
|
|
799
|
+
return [];
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
let headTurnId: string | undefined;
|
|
804
|
+
let resetIndex = 0;
|
|
805
|
+
const insert = this.db.prepare(`
|
|
806
|
+
INSERT OR IGNORE INTO turn_parent_links (
|
|
807
|
+
turn_id, local_session_id, parent_turn_id, created_at
|
|
808
|
+
) VALUES (?, ?, ?, ?)
|
|
809
|
+
`);
|
|
810
|
+
const backfill = this.db.transaction(() => {
|
|
811
|
+
for (const turn of turns) {
|
|
812
|
+
while (resetIndex < resets.length && resets[resetIndex]!.at <= turn.started_at) {
|
|
813
|
+
headTurnId = resets[resetIndex]!.resetTurnId;
|
|
814
|
+
resetIndex += 1;
|
|
815
|
+
}
|
|
816
|
+
if (!existing.has(turn.turn_id)) {
|
|
817
|
+
insert.run(turn.turn_id, localSessionId, headTurnId ?? null, new Date().toISOString());
|
|
818
|
+
}
|
|
819
|
+
headTurnId = turn.turn_id;
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
backfill();
|
|
823
|
+
}
|
|
824
|
+
|
|
629
825
|
saveTurnDelivery(
|
|
630
826
|
turnId: string,
|
|
631
827
|
patch: { progressMessageId?: string; lastCardHash?: string },
|
|
@@ -784,6 +980,31 @@ export class StateStore {
|
|
|
784
980
|
return rows.map(mapSession);
|
|
785
981
|
}
|
|
786
982
|
|
|
983
|
+
findLatestMessageIdForSession(localSessionId: string, contextKey?: string): string | undefined {
|
|
984
|
+
const row = this.db.prepare(`
|
|
985
|
+
SELECT message_id
|
|
986
|
+
FROM message_reactions
|
|
987
|
+
WHERE local_session_id = ?
|
|
988
|
+
AND (? IS NULL OR context_key = ?)
|
|
989
|
+
ORDER BY created_at DESC, rowid DESC
|
|
990
|
+
LIMIT 1
|
|
991
|
+
`).get(localSessionId, contextKey ?? null, contextKey ?? null) as { message_id: string } | undefined;
|
|
992
|
+
return row?.message_id;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
findLatestMessageIdForContext(contextKey: string): string | undefined {
|
|
996
|
+
const row = this.db.prepare(`
|
|
997
|
+
SELECT json_extract(payload_json, '$.messageId') AS message_id
|
|
998
|
+
FROM audit_events
|
|
999
|
+
WHERE context_key = ?
|
|
1000
|
+
AND event_type = 'incoming_message'
|
|
1001
|
+
AND json_type(payload_json, '$.messageId') = 'text'
|
|
1002
|
+
ORDER BY id DESC
|
|
1003
|
+
LIMIT 1
|
|
1004
|
+
`).get(contextKey) as { message_id: string } | undefined;
|
|
1005
|
+
return row?.message_id;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
787
1008
|
getServerActivityState(): {
|
|
788
1009
|
runningSessions: number;
|
|
789
1010
|
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
|
}
|
|
@@ -11,9 +11,15 @@ export type SafeRestartPhase =
|
|
|
11
11
|
| "restarting"
|
|
12
12
|
| "cancelled";
|
|
13
13
|
|
|
14
|
+
export interface RestartNotificationTarget {
|
|
15
|
+
contextKey: string;
|
|
16
|
+
replyMessageId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
export interface SafeRestartStatus {
|
|
15
20
|
scheduleId: number;
|
|
16
21
|
reason: string;
|
|
22
|
+
notificationTargets?: RestartNotificationTarget[];
|
|
17
23
|
phase: SafeRestartPhase;
|
|
18
24
|
activity: ServerActivityState;
|
|
19
25
|
remainingMs?: number;
|
|
@@ -21,7 +27,7 @@ export interface SafeRestartStatus {
|
|
|
21
27
|
|
|
22
28
|
export interface SafeRestartSchedulerOptions {
|
|
23
29
|
readActivity(): ServerActivityState;
|
|
24
|
-
onReady(reason: string): void | Promise<void>;
|
|
30
|
+
onReady(reason: string, notificationTargets: RestartNotificationTarget[]): void | Promise<void>;
|
|
25
31
|
onStatus?(status: SafeRestartStatus): void | Promise<void>;
|
|
26
32
|
onStatusError?(error: unknown): void;
|
|
27
33
|
quietPeriodMs?: number;
|
|
@@ -33,6 +39,7 @@ export class SafeRestartScheduler {
|
|
|
33
39
|
private polling?: Promise<void>;
|
|
34
40
|
private pollAgain = false;
|
|
35
41
|
private reason?: string;
|
|
42
|
+
private notificationTargets: RestartNotificationTarget[] = [];
|
|
36
43
|
private idleSince?: number;
|
|
37
44
|
private idleInboundAt?: string;
|
|
38
45
|
private scheduleId = 0;
|
|
@@ -47,9 +54,13 @@ export class SafeRestartScheduler {
|
|
|
47
54
|
return this.reason;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
|
-
schedule(reason: string): boolean {
|
|
57
|
+
schedule(reason: string, notificationTarget?: RestartNotificationTarget): boolean {
|
|
51
58
|
const newlyScheduled = !this.reason;
|
|
52
|
-
if (newlyScheduled)
|
|
59
|
+
if (newlyScheduled) {
|
|
60
|
+
this.scheduleId += 1;
|
|
61
|
+
this.notificationTargets = [];
|
|
62
|
+
}
|
|
63
|
+
this.addNotificationTarget(notificationTarget);
|
|
53
64
|
this.reason = reason;
|
|
54
65
|
this.idleSince = undefined;
|
|
55
66
|
this.idleInboundAt = undefined;
|
|
@@ -73,10 +84,12 @@ export class SafeRestartScheduler {
|
|
|
73
84
|
const reason = this.reason;
|
|
74
85
|
if (!reason || this.scheduleId !== expectedScheduleId) return false;
|
|
75
86
|
const activity = this.options.readActivity();
|
|
87
|
+
const notificationTargets = this.notificationTargetsSnapshot();
|
|
76
88
|
this.clear();
|
|
77
89
|
await this.emitStatus({
|
|
78
90
|
scheduleId: expectedScheduleId,
|
|
79
91
|
reason,
|
|
92
|
+
notificationTargets,
|
|
80
93
|
phase: "cancelled",
|
|
81
94
|
activity,
|
|
82
95
|
});
|
|
@@ -87,6 +100,7 @@ export class SafeRestartScheduler {
|
|
|
87
100
|
if (this.timer) clearInterval(this.timer);
|
|
88
101
|
this.timer = undefined;
|
|
89
102
|
this.reason = undefined;
|
|
103
|
+
this.notificationTargets = [];
|
|
90
104
|
this.idleSince = undefined;
|
|
91
105
|
this.idleInboundAt = undefined;
|
|
92
106
|
}
|
|
@@ -101,6 +115,7 @@ export class SafeRestartScheduler {
|
|
|
101
115
|
await this.emitStatus({
|
|
102
116
|
scheduleId: this.scheduleId,
|
|
103
117
|
reason,
|
|
118
|
+
notificationTargets: this.notificationTargetsSnapshot(),
|
|
104
119
|
phase: state.runningSessions > 0 ? "waiting_tasks" : "waiting_delivery",
|
|
105
120
|
activity: state,
|
|
106
121
|
});
|
|
@@ -111,12 +126,26 @@ export class SafeRestartScheduler {
|
|
|
111
126
|
if (this.idleSince === undefined || this.idleInboundAt !== latestInboundAt) {
|
|
112
127
|
this.idleSince = Date.now();
|
|
113
128
|
this.idleInboundAt = latestInboundAt;
|
|
114
|
-
await this.emitStatus({
|
|
129
|
+
await this.emitStatus({
|
|
130
|
+
scheduleId: this.scheduleId,
|
|
131
|
+
reason,
|
|
132
|
+
notificationTargets: this.notificationTargetsSnapshot(),
|
|
133
|
+
phase: "countdown",
|
|
134
|
+
activity: state,
|
|
135
|
+
remainingMs: quietPeriodMs,
|
|
136
|
+
});
|
|
115
137
|
return;
|
|
116
138
|
}
|
|
117
139
|
const remainingMs = Math.max(0, quietPeriodMs - (Date.now() - this.idleSince));
|
|
118
140
|
if (remainingMs > 0) {
|
|
119
|
-
await this.emitStatus({
|
|
141
|
+
await this.emitStatus({
|
|
142
|
+
scheduleId: this.scheduleId,
|
|
143
|
+
reason,
|
|
144
|
+
notificationTargets: this.notificationTargetsSnapshot(),
|
|
145
|
+
phase: "countdown",
|
|
146
|
+
activity: state,
|
|
147
|
+
remainingMs,
|
|
148
|
+
});
|
|
120
149
|
return;
|
|
121
150
|
}
|
|
122
151
|
const confirmed = this.options.readActivity();
|
|
@@ -131,6 +160,7 @@ export class SafeRestartScheduler {
|
|
|
131
160
|
await this.emitStatus({
|
|
132
161
|
scheduleId: this.scheduleId,
|
|
133
162
|
reason,
|
|
163
|
+
notificationTargets: this.notificationTargetsSnapshot(),
|
|
134
164
|
phase: confirmed.runningSessions > 0 ? "waiting_tasks" : "waiting_delivery",
|
|
135
165
|
activity: confirmed,
|
|
136
166
|
});
|
|
@@ -138,6 +168,7 @@ export class SafeRestartScheduler {
|
|
|
138
168
|
await this.emitStatus({
|
|
139
169
|
scheduleId: this.scheduleId,
|
|
140
170
|
reason,
|
|
171
|
+
notificationTargets: this.notificationTargetsSnapshot(),
|
|
141
172
|
phase: "countdown",
|
|
142
173
|
activity: confirmed,
|
|
143
174
|
remainingMs: quietPeriodMs,
|
|
@@ -146,9 +177,17 @@ export class SafeRestartScheduler {
|
|
|
146
177
|
return;
|
|
147
178
|
}
|
|
148
179
|
const scheduleId = this.scheduleId;
|
|
180
|
+
const notificationTargets = this.notificationTargetsSnapshot();
|
|
149
181
|
this.clear();
|
|
150
|
-
await this.emitStatus({
|
|
151
|
-
|
|
182
|
+
await this.emitStatus({
|
|
183
|
+
scheduleId,
|
|
184
|
+
reason,
|
|
185
|
+
notificationTargets,
|
|
186
|
+
phase: "restarting",
|
|
187
|
+
activity: confirmed,
|
|
188
|
+
remainingMs: 0,
|
|
189
|
+
});
|
|
190
|
+
await this.options.onReady(reason, notificationTargets);
|
|
152
191
|
}
|
|
153
192
|
|
|
154
193
|
private requestPoll(): void {
|
|
@@ -175,4 +214,36 @@ export class SafeRestartScheduler {
|
|
|
175
214
|
this.options.onStatusError?.(error);
|
|
176
215
|
});
|
|
177
216
|
}
|
|
217
|
+
|
|
218
|
+
private addNotificationTarget(value: RestartNotificationTarget | undefined): void {
|
|
219
|
+
const target = normalizedNotificationTarget(value);
|
|
220
|
+
if (!target) return;
|
|
221
|
+
const existingIndex = this.notificationTargets.findIndex(
|
|
222
|
+
(candidate) => candidate.contextKey === target.contextKey,
|
|
223
|
+
);
|
|
224
|
+
if (existingIndex < 0) {
|
|
225
|
+
this.notificationTargets.push(target);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const existing = this.notificationTargets[existingIndex]!;
|
|
229
|
+
if (!existing.replyMessageId && target.replyMessageId) {
|
|
230
|
+
this.notificationTargets[existingIndex] = target;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private notificationTargetsSnapshot(): RestartNotificationTarget[] {
|
|
235
|
+
return this.notificationTargets.map((target) => ({ ...target }));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizedNotificationTarget(
|
|
240
|
+
value: RestartNotificationTarget | undefined,
|
|
241
|
+
): RestartNotificationTarget | undefined {
|
|
242
|
+
const contextKey = value?.contextKey.trim();
|
|
243
|
+
if (!contextKey) return undefined;
|
|
244
|
+
const replyMessageId = value?.replyMessageId?.trim();
|
|
245
|
+
return {
|
|
246
|
+
contextKey,
|
|
247
|
+
...(replyMessageId ? { replyMessageId } : {}),
|
|
248
|
+
};
|
|
178
249
|
}
|
|
@@ -1,10 +1,36 @@
|
|
|
1
|
+
export const RESTART_GROUP_CONTEXTS_ENV = "AGENT_BOT_RESTART_GROUP_CONTEXTS";
|
|
2
|
+
|
|
1
3
|
export function replacementSupervisorEnvironment(
|
|
2
4
|
restartReason: string,
|
|
3
5
|
environment: NodeJS.ProcessEnv = process.env,
|
|
6
|
+
restartGroupContextKeys: readonly string[] = [],
|
|
4
7
|
): NodeJS.ProcessEnv {
|
|
5
|
-
|
|
8
|
+
const result: NodeJS.ProcessEnv = {
|
|
6
9
|
...environment,
|
|
7
10
|
AGENT_BOT_START_DELAY_MS: "250",
|
|
8
11
|
AGENT_BOT_RESTART_REASON: restartReason,
|
|
9
12
|
};
|
|
13
|
+
const normalized = normalizedContextKeys(restartGroupContextKeys);
|
|
14
|
+
if (normalized.length > 0) {
|
|
15
|
+
result[RESTART_GROUP_CONTEXTS_ENV] = JSON.stringify(normalized);
|
|
16
|
+
} else {
|
|
17
|
+
delete result[RESTART_GROUP_CONTEXTS_ENV];
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function restartGroupContextKeysFromEnvironment(value: string | undefined): string[] {
|
|
23
|
+
if (!value?.trim()) return [];
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(value) as unknown;
|
|
26
|
+
return Array.isArray(parsed)
|
|
27
|
+
? normalizedContextKeys(parsed.filter((entry): entry is string => typeof entry === "string"))
|
|
28
|
+
: [];
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizedContextKeys(values: readonly string[]): string[] {
|
|
35
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
10
36
|
}
|