- commit
- 3a5a93d
- parent
- 684fcce
- author
- codex@macbookpro
- date
- 2026-03-31 15:57:27 +0800 CST
fix: avoid silent renewal link scan truncation
6 files changed,
+496,
-62
+145,
-0
1@@ -7049,6 +7049,151 @@ test("observeRenewalConversation gives targetId absolute priority over weaker pa
2 }
3 });
4
5+test("observeRenewalConversation paginates exact-match scans and reports diagnostics at the scan limit", async () => {
6+ const rootDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-scan-limit-"));
7+ const stateDir = join(rootDir, "state");
8+ const store = new ArtifactStore({
9+ artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
10+ databasePath: join(stateDir, ARTIFACT_DB_FILENAME)
11+ });
12+ const observedAt = Date.UTC(2026, 2, 30, 12, 8, 0);
13+ const diagnostics = [];
14+
15+ try {
16+ for (let index = 0; index < 50; index += 1) {
17+ const localConversationId = `lc-scan-limit-decoy-${index}`;
18+ const linkId = `link-scan-limit-decoy-${index}`;
19+ const decoyObservedAt = observedAt + 50_000 - index;
20+
21+ await store.upsertLocalConversation({
22+ localConversationId,
23+ platform: "chatgpt"
24+ });
25+ await store.upsertConversationLink({
26+ clientId: "firefox-chatgpt",
27+ createdAt: decoyObservedAt,
28+ isActive: true,
29+ linkId,
30+ localConversationId,
31+ observedAt: decoyObservedAt,
32+ pageTitle: `Decoy Thread ${index}`,
33+ pageUrl: `https://chatgpt.com/c/conv-scan-limit-decoy-${index}`,
34+ platform: "chatgpt",
35+ remoteConversationId: `conv-scan-limit-decoy-${index}`,
36+ routePath: `/c/conv-scan-limit-decoy-${index}`,
37+ routePattern: "/c/:conversationId",
38+ targetId: "tab:1",
39+ targetKind: "browser.proxy_delivery",
40+ targetPayload: {
41+ clientId: "firefox-chatgpt",
42+ tabId: 1
43+ },
44+ updatedAt: decoyObservedAt
45+ });
46+ }
47+
48+ await store.upsertLocalConversation({
49+ localConversationId: "lc-scan-limit-target",
50+ platform: "chatgpt"
51+ });
52+ await store.upsertConversationLink({
53+ clientId: "firefox-chatgpt",
54+ createdAt: observedAt - 60_000,
55+ isActive: true,
56+ linkId: "link-scan-limit-target",
57+ localConversationId: "lc-scan-limit-target",
58+ observedAt: observedAt - 60_000,
59+ pageTitle: "Shared Thread",
60+ pageUrl: "https://chatgpt.com/c/conv-scan-limit-target",
61+ platform: "chatgpt",
62+ remoteConversationId: "conv-scan-limit-target-existing",
63+ routePath: "/c/conv-scan-limit-target",
64+ routePattern: "/c/:conversationId",
65+ targetId: "tab:1",
66+ targetKind: "browser.proxy_delivery",
67+ targetPayload: {
68+ clientId: "firefox-chatgpt",
69+ pageUrl: "https://chatgpt.com/c/conv-scan-limit-target",
70+ tabId: 1
71+ },
72+ updatedAt: observedAt - 60_000
73+ });
74+
75+ const observation = await observeRenewalConversation({
76+ assistantMessageId: "msg-scan-limit",
77+ clientId: "firefox-chatgpt",
78+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
79+ observedAt,
80+ pageTitle: "Shared Thread",
81+ pageUrl: "https://chatgpt.com/c/conv-scan-limit-target",
82+ platform: "chatgpt",
83+ route: {
84+ assistantMessageId: "msg-scan-limit",
85+ conversationId: null,
86+ observedAt,
87+ organizationId: null,
88+ pageTitle: "Shared Thread",
89+ pageUrl: "https://chatgpt.com/c/conv-scan-limit-target",
90+ platform: "chatgpt",
91+ shellPage: false,
92+ tabId: 1
93+ },
94+ store
95+ });
96+
97+ assert.equal(observation.created, false);
98+ assert.equal(observation.resolvedBy, "active_link");
99+ assert.equal(observation.conversation.localConversationId, "lc-scan-limit-target");
100+ assert.equal(observation.link.linkId, "link-scan-limit-target");
101+
102+ const links = await store.listConversationLinks({
103+ clientId: "firefox-chatgpt",
104+ limit: 100,
105+ platform: "chatgpt",
106+ targetId: "tab:1"
107+ });
108+ assert.equal(links.length, 51);
109+ assert.equal(
110+ links.filter((link) => link.isActive).length,
111+ 1
112+ );
113+ assert.equal(
114+ links.find((link) => link.linkId === "link-scan-limit-target")?.isActive,
115+ true
116+ );
117+ assert.equal(
118+ links.filter((link) => link.linkId !== "link-scan-limit-target" && link.isActive).length,
119+ 0
120+ );
121+ assert.deepEqual(diagnostics, [
122+ {
123+ clientId: "firefox-chatgpt",
124+ code: "conversation_link_scan_limit_reached",
125+ limit: 50,
126+ offset: 0,
127+ operation: "resolve",
128+ platform: "chatgpt",
129+ signal: "target_id"
130+ },
131+ {
132+ clientId: "firefox-chatgpt",
133+ code: "conversation_link_scan_limit_reached",
134+ limit: 50,
135+ offset: 0,
136+ operation: "deactivate",
137+ platform: "chatgpt",
138+ signal: "target_id"
139+ }
140+ ]);
141+ } finally {
142+ store.close();
143+ rmSync(rootDir, {
144+ force: true,
145+ recursive: true
146+ });
147+ }
148+});
149+
150 test("ConductorRuntime persists renewal conversation links from browser.final_message and exposes renewal controls", async () => {
151 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-control-"));
152 const runtime = new ConductorRuntime(
1@@ -17,6 +17,7 @@ const DEFAULT_LINK_SCAN_LIMIT = 50;
2 export interface ObserveRenewalConversationInput {
3 assistantMessageId: string;
4 clientId?: string | null;
5+ onDiagnostic?: RenewalConversationDiagnosticSink;
6 observedAt: number;
7 pageTitle?: string | null;
8 pageUrl?: string | null;
9@@ -67,6 +68,28 @@ interface ConversationLinkScore {
10 weakSignalScore: number;
11 }
12
13+type RenewalConversationDiagnosticOperation = "deactivate" | "resolve";
14+type RenewalConversationDiagnosticSignal = "page_title" | "page_url" | "route_path" | "target_id";
15+type ConversationLinkQueryField = "pageTitle" | "pageUrl" | "routePath" | "targetId";
16+
17+interface ConversationLinkCandidateQuery {
18+ field: ConversationLinkQueryField;
19+ signal: RenewalConversationDiagnosticSignal;
20+ value: string | null;
21+}
22+
23+export interface RenewalConversationDiagnostic {
24+ code: "conversation_link_scan_limit_reached";
25+ clientId: string | null;
26+ limit: number;
27+ offset: number;
28+ operation: RenewalConversationDiagnosticOperation;
29+ platform: string;
30+ signal: RenewalConversationDiagnosticSignal;
31+}
32+
33+export type RenewalConversationDiagnosticSink = (diagnostic: RenewalConversationDiagnostic) => void;
34+
35 export async function getRenewalConversationDetail(
36 store: ArtifactStore,
37 localConversationId: string,
38@@ -126,8 +149,10 @@ export async function observeRenewalConversation(
39 remoteConversationId: input.remoteConversationId,
40 route: input.route ?? null
41 });
42+ const onDiagnostic = input.onDiagnostic ?? null;
43 const existingResolution = await resolveExistingConversationLink(input.store, {
44 clientId,
45+ onDiagnostic,
46 observedRoute,
47 platform
48 });
49@@ -143,6 +168,7 @@ export async function observeRenewalConversation(
50 clientId,
51 keepLinkId: existingResolution.link?.linkId ?? null,
52 localConversationId: conversation.localConversationId,
53+ onDiagnostic,
54 observedAt,
55 observedRoute,
56 platform
57@@ -233,22 +259,35 @@ async function deactivateConflictingConversationLinks(
58 clientId: string | null;
59 keepLinkId: string | null;
60 localConversationId: string;
61+ onDiagnostic: RenewalConversationDiagnosticSink | null;
62 observedAt: number;
63 observedRoute: NormalizedObservedRoute;
64 platform: string;
65 }
66 ): Promise<void> {
67- const filters: ListConversationLinksOptions = {
68- isActive: true,
69- limit: DEFAULT_LINK_SCAN_LIMIT,
70- platform: input.platform
71- };
72-
73- if (input.clientId != null) {
74- filters.clientId = input.clientId;
75- }
76-
77- const activeLinks = await store.listConversationLinks(filters);
78+ const activeLinks = await collectConversationLinkCandidates(store, {
79+ clientId: input.clientId,
80+ onDiagnostic: input.onDiagnostic,
81+ operation: "deactivate",
82+ platform: input.platform,
83+ queries: [
84+ {
85+ field: "targetId",
86+ signal: "target_id",
87+ value: input.observedRoute.targetId
88+ },
89+ {
90+ field: "pageUrl",
91+ signal: "page_url",
92+ value: input.observedRoute.pageUrl
93+ },
94+ {
95+ field: "routePath",
96+ signal: "route_path",
97+ value: input.observedRoute.routePath
98+ }
99+ ]
100+ });
101
102 for (const candidate of activeLinks) {
103 if (candidate.linkId === input.keepLinkId || candidate.isActive !== true) {
104@@ -488,6 +527,7 @@ async function resolveExistingConversationLink(
105 store: ArtifactStore,
106 input: {
107 clientId: string | null;
108+ onDiagnostic: RenewalConversationDiagnosticSink | null;
109 observedRoute: NormalizedObservedRoute;
110 platform: string;
111 }
112@@ -518,17 +558,34 @@ async function resolveExistingConversationLink(
113 };
114 }
115
116- const filters: ListConversationLinksOptions = {
117- isActive: true,
118- limit: DEFAULT_LINK_SCAN_LIMIT,
119- platform: input.platform
120- };
121-
122- if (input.clientId != null) {
123- filters.clientId = input.clientId;
124- }
125-
126- const candidates = await store.listConversationLinks(filters);
127+ const candidates = await collectConversationLinkCandidates(store, {
128+ clientId: input.clientId,
129+ onDiagnostic: input.onDiagnostic,
130+ operation: "resolve",
131+ platform: input.platform,
132+ queries: [
133+ {
134+ field: "targetId",
135+ signal: "target_id",
136+ value: input.observedRoute.targetId
137+ },
138+ {
139+ field: "pageUrl",
140+ signal: "page_url",
141+ value: input.observedRoute.pageUrl
142+ },
143+ {
144+ field: "routePath",
145+ signal: "route_path",
146+ value: input.observedRoute.routePath
147+ },
148+ {
149+ field: "pageTitle",
150+ signal: "page_title",
151+ value: input.observedRoute.pageTitle
152+ }
153+ ]
154+ });
155 let bestLink: ConversationLinkRecord | null = null;
156 let bestScore: ConversationLinkScore | null = null;
157
158@@ -555,6 +612,124 @@ async function resolveExistingConversationLink(
159 };
160 }
161
162+async function collectConversationLinkCandidates(
163+ store: ArtifactStore,
164+ input: {
165+ clientId: string | null;
166+ onDiagnostic: RenewalConversationDiagnosticSink | null;
167+ operation: RenewalConversationDiagnosticOperation;
168+ platform: string;
169+ queries: ConversationLinkCandidateQuery[];
170+ }
171+): Promise<ConversationLinkRecord[]> {
172+ const candidates = new Map<string, ConversationLinkRecord>();
173+
174+ for (const query of input.queries) {
175+ if (query.value == null) {
176+ continue;
177+ }
178+
179+ let offset = 0;
180+ let warned = false;
181+
182+ while (true) {
183+ const page = await store.listConversationLinks(
184+ buildConversationLinkCandidateFilters(input.clientId, input.platform, query, offset)
185+ );
186+
187+ for (const candidate of page) {
188+ candidates.set(candidate.linkId, candidate);
189+ }
190+
191+ if (page.length < DEFAULT_LINK_SCAN_LIMIT) {
192+ break;
193+ }
194+
195+ if (!warned) {
196+ reportConversationLinkScanLimitReached({
197+ clientId: input.clientId,
198+ limit: DEFAULT_LINK_SCAN_LIMIT,
199+ offset,
200+ onDiagnostic: input.onDiagnostic,
201+ operation: input.operation,
202+ platform: input.platform,
203+ signal: query.signal
204+ });
205+ warned = true;
206+ }
207+
208+ offset += page.length;
209+ }
210+ }
211+
212+ return [...candidates.values()];
213+}
214+
215+function buildConversationLinkCandidateFilters(
216+ clientId: string | null,
217+ platform: string,
218+ query: ConversationLinkCandidateQuery,
219+ offset: number
220+): ListConversationLinksOptions {
221+ const filters: ListConversationLinksOptions = {
222+ isActive: true,
223+ limit: DEFAULT_LINK_SCAN_LIMIT,
224+ offset,
225+ platform
226+ };
227+
228+ if (clientId != null) {
229+ filters.clientId = clientId;
230+ }
231+
232+ switch (query.field) {
233+ case "pageTitle":
234+ filters.pageTitle = query.value ?? undefined;
235+ return filters;
236+ case "pageUrl":
237+ filters.pageUrl = query.value ?? undefined;
238+ return filters;
239+ case "routePath":
240+ filters.routePath = query.value ?? undefined;
241+ return filters;
242+ case "targetId":
243+ filters.targetId = query.value ?? undefined;
244+ return filters;
245+ }
246+}
247+
248+function reportConversationLinkScanLimitReached(input: {
249+ clientId: string | null;
250+ limit: number;
251+ offset: number;
252+ onDiagnostic: RenewalConversationDiagnosticSink | null;
253+ operation: RenewalConversationDiagnosticOperation;
254+ platform: string;
255+ signal: RenewalConversationDiagnosticSignal;
256+}): void {
257+ const diagnostic: RenewalConversationDiagnostic = {
258+ clientId: input.clientId,
259+ code: "conversation_link_scan_limit_reached",
260+ limit: input.limit,
261+ offset: input.offset,
262+ operation: input.operation,
263+ platform: input.platform,
264+ signal: input.signal
265+ };
266+
267+ if (input.onDiagnostic != null) {
268+ input.onDiagnostic(diagnostic);
269+ return;
270+ }
271+
272+ console.warn(
273+ `[baa-renewal] conversation link scan limit reached `
274+ + `(operation=${input.operation} signal=${input.signal} `
275+ + `platform=${input.platform} clientId=${input.clientId ?? "any"} `
276+ + `limit=${input.limit} offset=${input.offset}); continuing with pagination.`
277+ );
278+}
279+
280 function compareConversationLinkScores(
281 candidateScore: ConversationLinkScore,
282 bestScore: ConversationLinkScore,
+36,
-0
1@@ -0,0 +1,36 @@
2+# FIX-BUG-031: conversation link 扫描上限不再静默截断
3+
4+## 执行状态
5+
6+- 已完成(2026-03-31,代码 + 自动化验证已落地)
7+
8+## 关联 Bug
9+
10+BUG-031-link-scan-limit-silent-truncation.md
11+
12+## 实际修改文件
13+
14+- `apps/conductor-daemon/src/renewal/conversations.ts`
15+- `apps/conductor-daemon/src/index.test.js`
16+- `packages/artifact-db/src/store.ts`
17+- `packages/artifact-db/src/types.ts`
18+- `packages/artifact-db/src/index.test.js`
19+
20+## 实际修改
21+
22+- 不再按 `platform + clientId` 粗扫前 50 条 active link,再在内存里筛候选。
23+- `resolveExistingConversationLink` 现在按 `targetId`、`pageUrl`、`routePath`、`pageTitle` 做精确候选查询,并在单页打满时继续分页,避免旧实现只看前 50 条导致漏选。
24+- `deactivateConflictingConversationLinks` 现在按 `targetId`、`pageUrl`、`routePath` 精确收集冲突候选,并在更新前先把候选页读全,避免 scan limit 命中时漏停用。
25+- 当任一候选查询页命中 `DEFAULT_LINK_SCAN_LIMIT = 50` 时,会发出 `conversation_link_scan_limit_reached` 诊断;默认走 `console.warn`,调用方也可以注入 sink 收集结构化诊断。
26+- 顺手修正了 `ArtifactStore.upsertConversationLink` 返回值与落库记录在 `createdAt` 上不一致的问题,保证重用已有 link 时返回值和数据库一致。
27+
28+## 验收标准
29+
30+1. active link 总量较多时,conversation link 的 resolve / deactivate 不再因为前 50 条截断而静默失败。
31+2. 候选查询命中单页上限时,日志里会留下明确的 `conversation_link_scan_limit_reached` warn/diagnostic。
32+3. 命中 scan limit 的回归测试可以证明:
33+ - resolve 能跨页找到真正的最佳 link
34+ - deactivate 能跨页停用其余冲突 link
35+4. 自动化验证通过:
36+ - `pnpm -C /Users/george/code/baa-conductor-bug-031/packages/artifact-db test`
37+ - `pnpm -C /Users/george/code/baa-conductor-bug-031/apps/conductor-daemon test`
+75,
-0
1@@ -537,6 +537,81 @@ test("ArtifactStore findConversationLinkByRemoteConversation only returns active
2 }
3 });
4
5+test("ArtifactStore listConversationLinks supports exact renewal identity filters", async () => {
6+ const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-link-filter-test-"));
7+ const stateDir = join(rootDir, "state");
8+ const databasePath = join(stateDir, ARTIFACT_DB_FILENAME);
9+ const artifactDir = join(stateDir, ARTIFACTS_DIRNAME);
10+ const store = new ArtifactStore({
11+ artifactDir,
12+ databasePath
13+ });
14+ const observedAt = Date.UTC(2026, 2, 30, 8, 5, 0);
15+
16+ try {
17+ await store.upsertLocalConversation({
18+ localConversationId: "lc_filter_1",
19+ platform: "chatgpt"
20+ });
21+ await store.upsertLocalConversation({
22+ localConversationId: "lc_filter_2",
23+ platform: "chatgpt"
24+ });
25+
26+ const matchingLink = await store.upsertConversationLink({
27+ clientId: "firefox-chatgpt",
28+ linkId: "link_filter_match",
29+ localConversationId: "lc_filter_1",
30+ observedAt,
31+ pageTitle: "Target Thread",
32+ pageUrl: "https://chatgpt.com/c/conv-filter-match",
33+ platform: "chatgpt",
34+ remoteConversationId: "conv-filter-match",
35+ routePath: "/c/conv-filter-match",
36+ routePattern: "/c/:conversationId",
37+ targetId: "tab:7",
38+ updatedAt: observedAt
39+ });
40+ await store.upsertConversationLink({
41+ clientId: "firefox-chatgpt",
42+ linkId: "link_filter_other",
43+ localConversationId: "lc_filter_2",
44+ observedAt: observedAt + 1_000,
45+ pageTitle: "Other Thread",
46+ pageUrl: "https://chatgpt.com/c/conv-filter-other",
47+ platform: "chatgpt",
48+ remoteConversationId: "conv-filter-other",
49+ routePath: "/c/conv-filter-other",
50+ routePattern: "/c/:conversationId",
51+ targetId: "tab:8",
52+ updatedAt: observedAt + 1_000
53+ });
54+
55+ assert.deepEqual(
56+ await store.listConversationLinks({ pageTitle: "Target Thread", platform: "chatgpt" }),
57+ [matchingLink]
58+ );
59+ assert.deepEqual(
60+ await store.listConversationLinks({ pageUrl: "https://chatgpt.com/c/conv-filter-match", platform: "chatgpt" }),
61+ [matchingLink]
62+ );
63+ assert.deepEqual(
64+ await store.listConversationLinks({ platform: "chatgpt", routePath: "/c/conv-filter-match" }),
65+ [matchingLink]
66+ );
67+ assert.deepEqual(
68+ await store.listConversationLinks({ platform: "chatgpt", targetId: "tab:7" }),
69+ [matchingLink]
70+ );
71+ } finally {
72+ store.close();
73+ rmSync(rootDir, {
74+ force: true,
75+ recursive: true
76+ });
77+ }
78+});
79+
80 test("ArtifactStore reuses the same null-remote conversation link for repeated route upserts", async () => {
81 const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-null-remote-upsert-test-"));
82 const stateDir = join(rootDir, "state");
+39,
-40
1@@ -780,47 +780,46 @@ export class ArtifactStore {
2 async listConversationLinks(
3 options: ListConversationLinksOptions = {}
4 ): Promise<ConversationLinkRecord[]> {
5- const conditions: string[] = [];
6- const params: Array<number | string | null> = [];
7-
8- if (options.localConversationId != null) {
9- conditions.push("local_conversation_id = ?");
10- params.push(options.localConversationId);
11- }
12-
13- if (options.platform != null) {
14- conditions.push("platform = ?");
15- params.push(options.platform);
16- }
17-
18- if (options.remoteConversationId != null) {
19- conditions.push("remote_conversation_id = ?");
20- params.push(options.remoteConversationId);
21- }
22-
23- if (options.clientId != null) {
24- conditions.push("client_id = ?");
25- params.push(options.clientId);
26- }
27-
28- if (options.isActive != null) {
29- conditions.push("is_active = ?");
30- params.push(options.isActive ? 1 : 0);
31- }
32+ const query = [
33+ "SELECT * FROM conversation_links",
34+ buildWhereClause(
35+ [
36+ buildCondition("local_conversation_id", options.localConversationId),
37+ buildCondition("platform", options.platform),
38+ buildCondition("remote_conversation_id", options.remoteConversationId),
39+ buildCondition("client_id", options.clientId),
40+ buildCondition("page_title", options.pageTitle),
41+ buildCondition("page_url", options.pageUrl),
42+ buildCondition("route_path", options.routePath),
43+ buildCondition("target_id", options.targetId),
44+ options.isActive == null ? null : "is_active = ?"
45+ ],
46+ "AND"
47+ ),
48+ "ORDER BY observed_at DESC, updated_at DESC",
49+ "LIMIT ?",
50+ "OFFSET ?"
51+ ]
52+ .filter(Boolean)
53+ .join(" ");
54
55 const rows = this.getRows<ConversationLinkRow>(
56- [
57- "SELECT * FROM conversation_links",
58- conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
59- "ORDER BY observed_at DESC, updated_at DESC",
60- "LIMIT ?",
61- "OFFSET ?"
62- ]
63- .filter(Boolean)
64- .join(" "),
65- ...params,
66- normalizeLimit(options.limit),
67- normalizeOffset(options.offset)
68+ query,
69+ ...buildQueryParams(
70+ [
71+ options.localConversationId ?? undefined,
72+ options.platform ?? undefined,
73+ options.remoteConversationId ?? undefined,
74+ options.clientId ?? undefined,
75+ options.pageTitle ?? undefined,
76+ options.pageUrl ?? undefined,
77+ options.routePath ?? undefined,
78+ options.targetId ?? undefined,
79+ options.isActive == null ? undefined : (options.isActive ? 1 : 0)
80+ ],
81+ normalizeLimit(options.limit),
82+ normalizeOffset(options.offset)
83+ )
84 );
85
86 return rows.map(mapConversationLinkRow);
87@@ -1504,7 +1503,7 @@ function buildConversationLinkRecord(
88 input: UpsertConversationLinkInput,
89 existing: ConversationLinkRecord | null
90 ): ConversationLinkRecord {
91- const createdAt = input.createdAt ?? existing?.createdAt ?? Date.now();
92+ const createdAt = existing?.createdAt ?? input.createdAt ?? Date.now();
93
94 return {
95 clientId: mergeOptionalString(input.clientId, existing?.clientId ?? null),
+4,
-0
1@@ -294,8 +294,12 @@ export interface ListConversationLinksOptions {
2 limit?: number;
3 localConversationId?: string;
4 offset?: number;
5+ pageTitle?: string;
6+ pageUrl?: string;
7 platform?: string;
8 remoteConversationId?: string;
9+ routePath?: string;
10+ targetId?: string;
11 }
12
13 export interface ListRenewalJobsOptions {