baa-conductor


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