- commit
- ce6e8af
- parent
- d8e883d
- author
- im_wower
- date
- 2026-04-03 08:40:17 +0800 CST
feat: start conversation-first renewal routing
4 files changed,
+139,
-70
Raw patch view.
1diff --git a/apps/conductor-daemon/src/index.test.js b/apps/conductor-daemon/src/index.test.js
2index a8427b699caeadba57c7b0c83c6e47d9c1ace747..b0db8b9bde131540b0fd906b10f150543e6f16ef 100644
3--- a/apps/conductor-daemon/src/index.test.js
4+++ b/apps/conductor-daemon/src/index.test.js
5@@ -3647,7 +3647,7 @@ test("renewal projector scans settled messages with cursor semantics and skips i
6 id: "msg_missing_tab_id",
7 observedAt: nowMs - 3_000,
8 platform: "claude",
9- rawText: "missing tab id should not project",
10+ rawText: "missing tab id but conversation route should still project",
11 role: "assistant"
12 });
13
14@@ -3684,23 +3684,40 @@ test("renewal projector scans settled messages with cursor semantics and skips i
15 assert.equal(tick.decision, "scheduled");
16
17 const jobs = await artifactStore.listRenewalJobs({});
18- assert.equal(jobs.length, 1);
19- assert.equal(jobs[0].messageId, autoMessage.id);
20- assert.equal(jobs[0].status, "pending");
21- assert.equal(jobs[0].payloadKind, "json");
22- assert.equal(typeof jobs[0].logPath, "string");
23- assert.match(jobs[0].logPath, /\.jsonl$/u);
24-
25- const payload = JSON.parse(jobs[0].payload);
26+ assert.equal(jobs.length, 2);
27+ assert.deepEqual(
28+ jobs.map((job) => job.messageId).sort(),
29+ [autoMessage.id, missingTabIdMessage.id].sort()
30+ );
31+
32+ const autoJob = jobs.find((job) => job.messageId === autoMessage.id);
33+ const routeOnlyJob = jobs.find((job) => job.messageId === missingTabIdMessage.id);
34+ assert.ok(autoJob);
35+ assert.ok(routeOnlyJob);
36+ assert.equal(autoJob.status, "pending");
37+ assert.equal(autoJob.payloadKind, "json");
38+ assert.equal(typeof autoJob.logPath, "string");
39+ assert.match(autoJob.logPath, /\.jsonl$/u);
40+ assert.equal(routeOnlyJob.status, "pending");
41+ assert.equal(routeOnlyJob.payloadKind, "json");
42+
43+ const payload = JSON.parse(autoJob.payload);
44 assert.equal(payload.template, "summary_with_link");
45 assert.match(payload.text, /\[renewal\] Context summary:/u);
46 assert.equal(payload.sourceMessage.id, autoMessage.id);
47 assert.equal(payload.linkUrl, "https://claude.ai/chat/conv_auto");
48
49- const targetSnapshot = JSON.parse(jobs[0].targetSnapshot);
50+ const targetSnapshot = JSON.parse(autoJob.targetSnapshot);
51 assert.equal(targetSnapshot.target.kind, "browser.proxy_delivery");
52 assert.equal(targetSnapshot.route.pattern, "/chat/:conversationId");
53 assert.equal(targetSnapshot.target.payload.tabId, 4);
54+ assert.equal(targetSnapshot.target.id, "client:firefox-auto");
55+
56+ const routeOnlyTargetSnapshot = JSON.parse(routeOnlyJob.targetSnapshot);
57+ assert.equal(routeOnlyTargetSnapshot.target.kind, "browser.proxy_delivery");
58+ assert.equal(routeOnlyTargetSnapshot.pageUrl, "https://claude.ai/chat/conv_missing_tab_id");
59+ assert.equal(routeOnlyTargetSnapshot.target.payload.clientId, "firefox-missing-tab-id");
60+ assert.equal(routeOnlyTargetSnapshot.target.payload.tabId, undefined);
61
62 const cursorState = await repository.getSystemState("renewal.projector.cursor");
63 assert.ok(cursorState);
64@@ -3712,9 +3729,12 @@ test("renewal projector scans settled messages with cursor semantics and skips i
65
66 const entries = await waitForJsonlEntries(
67 logsDir,
68- (items) => items.some((entry) => entry.runner === "renewal.projector" && entry.stage === "job_projected")
69+ (items) => items.filter((entry) => entry.runner === "renewal.projector" && entry.stage === "job_projected").length >= 2
70+ );
71+ assert.equal(
72+ entries.filter((entry) => entry.runner === "renewal.projector" && entry.stage === "job_projected").length,
73+ 2
74 );
75- assert.ok(entries.find((entry) => entry.runner === "renewal.projector" && entry.stage === "job_projected"));
76 assert.ok(
77 entries.find(
78 (entry) => entry.runner === "renewal.projector" && entry.stage === "message_skipped" && entry.result === "automation_manual"
79@@ -3747,13 +3767,6 @@ test("renewal projector scans settled messages with cursor semantics and skips i
80 && entry.route_unavailable_reason === "shell_page"
81 )
82 );
83- assert.ok(
84- routeUnavailableEntries.find(
85- (entry) =>
86- entry.message_id === missingTabIdMessage.id
87- && entry.route_unavailable_reason === "missing_tab_id"
88- )
89- );
90 assert.ok(
91 entries.find(
92 (entry) =>
93@@ -3960,7 +3973,7 @@ test("shouldRenew keeps route_unavailable while exposing structured route failur
94 }
95 },
96 {
97- expectedReason: "missing_tab_id",
98+ expectedReason: "missing_delivery_context",
99 link: {
100 targetId: null,
101 targetPayload: JSON.stringify({
102@@ -8972,7 +8985,7 @@ test("observeRenewalConversation reuses the same link when remote conversation i
103 }
104 });
105
106-test("observeRenewalConversation gives targetId absolute priority over weaker page signals", async () => {
107+test("observeRenewalConversation prefers conversation-derived business targets over stale legacy tab links", async () => {
108 const rootDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-target-priority-"));
109 const stateDir = join(rootDir, "state");
110 const store = new ArtifactStore({
111@@ -9056,24 +9069,25 @@ test("observeRenewalConversation gives targetId absolute priority over weaker pa
112
113 assert.equal(observation.created, false);
114 assert.equal(observation.resolvedBy, "active_link");
115- assert.equal(observation.conversation.localConversationId, "lc-target-priority-tab-1");
116- assert.equal(observation.link.linkId, "link-target-priority-tab-1");
117- assert.equal(observation.link.targetId, "tab:1");
118+ assert.equal(observation.conversation.localConversationId, "lc-target-priority-tab-2");
119+ assert.equal(observation.link.linkId, "link-target-priority-tab-2");
120+ assert.equal(observation.link.targetId, "conversation:chatgpt:conv-target-priority");
121 assert.equal(observation.link.pageTitle, "Shared Thread");
122 assert.equal(observation.link.pageUrl, "https://chatgpt.com/c/conv-target-priority");
123 assert.equal(observation.link.routePath, "/c/conv-target-priority");
124 assert.equal(observation.link.remoteConversationId, "conv-target-priority");
125
126- const preservedLink = await store.getConversationLink("link-target-priority-tab-1");
127+ const preservedLink = await store.getConversationLink("link-target-priority-tab-2");
128 assert.ok(preservedLink);
129 assert.equal(preservedLink.isActive, true);
130- assert.equal(preservedLink.localConversationId, "lc-target-priority-tab-1");
131+ assert.equal(preservedLink.localConversationId, "lc-target-priority-tab-2");
132 assert.equal(preservedLink.remoteConversationId, "conv-target-priority");
133+ assert.equal(preservedLink.targetId, "conversation:chatgpt:conv-target-priority");
134
135- const deactivatedLink = await store.getConversationLink("link-target-priority-tab-2");
136- assert.ok(deactivatedLink);
137- assert.equal(deactivatedLink.isActive, false);
138- assert.equal(deactivatedLink.localConversationId, "lc-target-priority-tab-2");
139+ const legacyTabLink = await store.getConversationLink("link-target-priority-tab-1");
140+ assert.ok(legacyTabLink);
141+ assert.equal(legacyTabLink.isActive, true);
142+ assert.equal(legacyTabLink.localConversationId, "lc-target-priority-tab-1");
143
144 assert.deepEqual(
145 await store.findConversationLinkByRemoteConversation("chatgpt", "conv-target-priority"),
146@@ -9184,46 +9198,37 @@ test("observeRenewalConversation paginates exact-match scans and reports diagnos
147 assert.equal(observation.resolvedBy, "active_link");
148 assert.equal(observation.conversation.localConversationId, "lc-scan-limit-target");
149 assert.equal(observation.link.linkId, "link-scan-limit-target");
150+ assert.equal(observation.link.targetId, "conversation:chatgpt:conv-scan-limit-target");
151
152- const links = await store.listConversationLinks({
153+ const legacyTabLinks = await store.listConversationLinks({
154 clientId: "firefox-chatgpt",
155 limit: 100,
156 platform: "chatgpt",
157 targetId: "tab:1"
158 });
159- assert.equal(links.length, 51);
160+ assert.equal(legacyTabLinks.length, 50);
161 assert.equal(
162- links.filter((link) => link.isActive).length,
163- 1
164+ legacyTabLinks.filter((link) => link.isActive).length,
165+ 50
166 );
167+
168+ const conversationLinks = await store.listConversationLinks({
169+ clientId: "firefox-chatgpt",
170+ limit: 10,
171+ platform: "chatgpt",
172+ targetId: "conversation:chatgpt:conv-scan-limit-target"
173+ });
174+ assert.equal(conversationLinks.length, 1);
175+ assert.equal(conversationLinks[0]?.linkId, "link-scan-limit-target");
176 assert.equal(
177- links.find((link) => link.linkId === "link-scan-limit-target")?.isActive,
178- true
179+ conversationLinks.filter((link) => link.isActive).length,
180+ 1
181 );
182 assert.equal(
183- links.filter((link) => link.linkId !== "link-scan-limit-target" && link.isActive).length,
184- 0
185+ conversationLinks.find((link) => link.linkId === "link-scan-limit-target")?.isActive,
186+ true
187 );
188- assert.deepEqual(diagnostics, [
189- {
190- clientId: "firefox-chatgpt",
191- code: "conversation_link_scan_limit_reached",
192- limit: 50,
193- offset: 0,
194- operation: "resolve",
195- platform: "chatgpt",
196- signal: "target_id"
197- },
198- {
199- clientId: "firefox-chatgpt",
200- code: "conversation_link_scan_limit_reached",
201- limit: 50,
202- offset: 0,
203- operation: "deactivate",
204- platform: "chatgpt",
205- signal: "target_id"
206- }
207- ]);
208+ assert.deepEqual(diagnostics, []);
209 } finally {
210 store.close();
211 rmSync(rootDir, {
212diff --git a/apps/conductor-daemon/src/renewal/conversations.ts b/apps/conductor-daemon/src/renewal/conversations.ts
213index 14f40335057990f307a9dca08d8419b6a16a5f35..d6705ef4cfde88879b2dbd6e007dc2d099710f9f 100644
214--- a/apps/conductor-daemon/src/renewal/conversations.ts
215+++ b/apps/conductor-daemon/src/renewal/conversations.ts
216@@ -18,6 +18,7 @@ import {
217
218 const LOCAL_CONVERSATION_ID_PREFIX = "lc_";
219 const CONVERSATION_LINK_ID_PREFIX = "link_";
220+const CONVERSATION_TARGET_ID_PREFIX = "conversation";
221 const DEFAULT_LINK_SCAN_LIMIT = 50;
222
223 export interface ObserveRenewalConversationInput {
224@@ -53,6 +54,7 @@ export class RenewalConversationNotFoundError extends Error {
225 }
226
227 interface NormalizedObservedRoute {
228+ legacyTargetId: string | null;
229 pageTitle: string | null;
230 pageUrl: string | null;
231 remoteConversationId: string | null;
232@@ -297,6 +299,14 @@ async function deactivateConflictingConversationLinks(
233 signal: "target_id",
234 value: input.observedRoute.targetId
235 },
236+ {
237+ field: "targetId",
238+ signal: "target_id",
239+ value:
240+ input.observedRoute.legacyTargetId != null && input.observedRoute.legacyTargetId !== input.observedRoute.targetId
241+ ? input.observedRoute.legacyTargetId
242+ : null
243+ },
244 {
245 field: "pageUrl",
246 signal: "page_url",
247@@ -342,6 +352,10 @@ function shouldDeactivateLink(
248 return true;
249 }
250
251+ if (observedRoute.legacyTargetId != null && candidate.targetId === observedRoute.legacyTargetId) {
252+ return true;
253+ }
254+
255 if (observedRoute.pageUrl != null && candidate.pageUrl === observedRoute.pageUrl) {
256 return true;
257 }
258@@ -371,11 +385,19 @@ function buildObservedRoute(
259 ?? extractConversationIdFromPageUrl(platform, pageUrl);
260 const routeSnapshot = buildRouteSnapshot(platform, pageUrl, remoteConversationId);
261 const tabId = input.route?.tabId ?? null;
262- const targetId = Number.isInteger(tabId)
263- ? `tab:${tabId}`
264- : (pageUrl ?? input.clientId ?? null);
265+ const shellPage = input.route?.shellPage === true;
266+ const legacyTargetId = Number.isInteger(tabId) ? `tab:${tabId}` : null;
267+ const targetId = buildObservedTargetId({
268+ clientId: input.clientId,
269+ pageUrl,
270+ platform,
271+ remoteConversationId,
272+ shellPage,
273+ tabId
274+ });
275
276 return {
277+ legacyTargetId,
278 pageTitle,
279 pageUrl,
280 remoteConversationId,
281@@ -383,19 +405,42 @@ function buildObservedRoute(
282 routePath: routeSnapshot.routePath,
283 routePattern: routeSnapshot.routePattern,
284 targetId,
285- targetKind: input.route?.shellPage === true ? "browser.shell_page" : "browser.proxy_delivery",
286+ targetKind: shellPage ? "browser.shell_page" : "browser.proxy_delivery",
287 targetPayload: compactObject({
288 clientId: input.clientId ?? undefined,
289 conversationId: remoteConversationId ?? undefined,
290 organizationId: normalizeOptionalString(input.route?.organizationId) ?? undefined,
291 pageTitle: pageTitle ?? undefined,
292 pageUrl: pageUrl ?? undefined,
293- shellPage: input.route?.shellPage === true ? true : undefined,
294+ shellPage: shellPage ? true : undefined,
295 tabId: tabId ?? undefined
296 })
297 };
298 }
299
300+function buildObservedTargetId(input: {
301+ clientId: string | null;
302+ pageUrl: string | null;
303+ platform: string;
304+ remoteConversationId: string | null;
305+ shellPage: boolean;
306+ tabId: number | null;
307+}): string | null {
308+ if (!input.shellPage && input.remoteConversationId != null) {
309+ return buildConversationTargetId(input.platform, input.remoteConversationId);
310+ }
311+
312+ if (Number.isInteger(input.tabId)) {
313+ return `tab:${input.tabId}`;
314+ }
315+
316+ return input.pageUrl ?? input.clientId ?? null;
317+}
318+
319+function buildConversationTargetId(platform: string, remoteConversationId: string): string {
320+ return `${CONVERSATION_TARGET_ID_PREFIX}:${platform}:${encodeURIComponent(remoteConversationId)}`;
321+}
322+
323 function buildRouteSnapshot(
324 platform: string,
325 pageUrl: string | null,
326@@ -592,6 +637,14 @@ async function resolveExistingConversationLink(
327 signal: "target_id",
328 value: input.observedRoute.targetId
329 },
330+ {
331+ field: "targetId",
332+ signal: "target_id",
333+ value:
334+ input.observedRoute.legacyTargetId != null && input.observedRoute.legacyTargetId !== input.observedRoute.targetId
335+ ? input.observedRoute.legacyTargetId
336+ : null
337+ },
338 {
339 field: "pageUrl",
340 signal: "page_url",
341diff --git a/apps/conductor-daemon/src/renewal/dispatcher.ts b/apps/conductor-daemon/src/renewal/dispatcher.ts
342index c4441df406e45e4656570fa1b2d2cf1d6ba2c11f..bf5e53e849932198ae4ba999dd447c113f36b597 100644
343--- a/apps/conductor-daemon/src/renewal/dispatcher.ts
344+++ b/apps/conductor-daemon/src/renewal/dispatcher.ts
345@@ -78,7 +78,7 @@ interface RenewalDispatchTarget {
346 pageTitle: string | null;
347 pageUrl: string | null;
348 platform: string;
349- tabId: number;
350+ tabId: number | null;
351 }
352
353 interface RenewalDispatchContext {
354@@ -1021,18 +1021,23 @@ function resolveDispatchTarget(
355
356 const payload = isPlainRecord(snapshot.target.payload) ? snapshot.target.payload : null;
357 const shellPage = readBoolean(payload, "shellPage") === true;
358+ const routeParams = isPlainRecord(snapshot.route.params) ? snapshot.route.params : null;
359 const tabId = readPositiveInteger(payload, "tabId") ?? parseTabId(snapshot.target.id);
360+ const conversationId =
361+ normalizeOptionalString(readString(payload, "conversationId"))
362+ ?? normalizeOptionalString(readString(routeParams, "conversationId"));
363+ const pageUrl = normalizeOptionalString(readString(payload, "pageUrl")) ?? snapshot.pageUrl;
364
365- if (shellPage || tabId == null) {
366+ if (shellPage || (conversationId == null && pageUrl == null && tabId == null)) {
367 return null;
368 }
369
370 return {
371 clientId: normalizeOptionalString(readString(payload, "clientId")) ?? snapshot.clientId,
372- conversationId: normalizeOptionalString(readString(payload, "conversationId")),
373+ conversationId,
374 organizationId: normalizeOptionalString(readString(payload, "organizationId")),
375 pageTitle: normalizeOptionalString(readString(payload, "pageTitle")) ?? snapshot.pageTitle,
376- pageUrl: normalizeOptionalString(readString(payload, "pageUrl")) ?? snapshot.pageUrl,
377+ pageUrl,
378 platform: snapshot.platform,
379 tabId
380 };
381diff --git a/apps/conductor-daemon/src/renewal/projector.ts b/apps/conductor-daemon/src/renewal/projector.ts
382index 0a9fcf056ac510adb20344f8ac0f3b595199eb82..f27e4d882af069095d9a2fc29cf0873ac2594c91 100644
383--- a/apps/conductor-daemon/src/renewal/projector.ts
384+++ b/apps/conductor-daemon/src/renewal/projector.ts
385@@ -45,7 +45,7 @@ export type RenewalProjectorSkipReason =
386
387 export type RenewalRouteUnavailableReason =
388 | "inactive_link"
389- | "missing_tab_id"
390+ | "missing_delivery_context"
391 | "shell_page"
392 | "target_kind_not_proxy_delivery";
393
394@@ -575,6 +575,12 @@ function hasAvailableRoute(link: ConversationLinkRecord): RenewalRouteAvailabili
395
396 const targetPayload = parseJsonRecord(link.targetPayload);
397 const shellPage = targetPayload?.shellPage === true;
398+ const conversationId =
399+ normalizeOptionalString(typeof targetPayload?.conversationId === "string" ? targetPayload.conversationId : null)
400+ ?? normalizeOptionalString(link.remoteConversationId);
401+ const pageUrl =
402+ normalizeOptionalString(typeof targetPayload?.pageUrl === "string" ? targetPayload.pageUrl : null)
403+ ?? normalizeOptionalString(link.pageUrl);
404 const tabId =
405 (typeof targetPayload?.tabId === "number" && Number.isInteger(targetPayload.tabId) && targetPayload.tabId > 0)
406 ? targetPayload.tabId
407@@ -587,10 +593,10 @@ function hasAvailableRoute(link: ConversationLinkRecord): RenewalRouteAvailabili
408 };
409 }
410
411- if (tabId == null) {
412+ if (conversationId == null && pageUrl == null && tabId == null) {
413 return {
414 available: false,
415- reason: "missing_tab_id"
416+ reason: "missing_delivery_context"
417 };
418 }
419