- commit
- bb4d393
- parent
- 711a4d3
- author
- codex@macbookpro
- date
- 2026-03-30 16:58:48 +0800 CST
feat: add renewal dispatcher ops surfaces
8 files changed,
+1536,
-14
+502,
-0
1@@ -30,6 +30,7 @@ import {
2 PersistentBaaLiveInstructionMessageDeduper,
3 PersistentBaaLiveInstructionSnapshotStore,
4 createFetchControlApiClient,
5+ createRenewalDispatcherRunner,
6 createRenewalProjectorRunner,
7 executeBaaInstruction,
8 extractBaaInstructionBlocks,
9@@ -250,6 +251,41 @@ async function createLocalApiFixture(options = {}) {
10 };
11 }
12
13+function createTimedJobRunnerContext({
14+ artifactStore,
15+ batchId = "timed-jobs-test-batch",
16+ config = {
17+ intervalMs: 5_000,
18+ maxMessagesPerTick: 10,
19+ maxTasksPerTick: 10,
20+ settleDelayMs: 0
21+ },
22+ logDir = null,
23+ trigger = "manual"
24+} = {}) {
25+ const entries = [];
26+
27+ return {
28+ entries,
29+ context: {
30+ artifactStore: artifactStore ?? null,
31+ batchId,
32+ config,
33+ controllerId: "mini-main",
34+ host: "mini",
35+ log: (input) => {
36+ entries.push(input);
37+ },
38+ logDir,
39+ maxMessagesPerTick: config.maxMessagesPerTick,
40+ maxTasksPerTick: config.maxTasksPerTick,
41+ settleDelayMs: config.settleDelayMs,
42+ term: 2,
43+ trigger
44+ }
45+ };
46+}
47+
48 function createInstructionEnvelope({
49 blockIndex = 0,
50 params = null,
51@@ -3151,6 +3187,353 @@ test("renewal projector scans settled messages with cursor semantics and skips i
52 }
53 });
54
55+test("renewal dispatcher sends due pending jobs through browser.proxy_delivery and marks them done", async () => {
56+ const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-success-"));
57+ const stateDir = join(rootDir, "state");
58+ const logsDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-success-logs-"));
59+ const artifactStore = new ArtifactStore({
60+ artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
61+ databasePath: join(stateDir, ARTIFACT_DB_FILENAME)
62+ });
63+ const nowMs = Date.UTC(2026, 2, 30, 12, 0, 0);
64+ const browserCalls = [];
65+ const runner = createRenewalDispatcherRunner({
66+ browserBridge: {
67+ proxyDelivery(input) {
68+ browserCalls.push(input);
69+ return {
70+ clientId: input.clientId || "firefox-chatgpt",
71+ connectionId: "conn-firefox-chatgpt",
72+ dispatchedAt: nowMs,
73+ requestId: "proxy-dispatch-1",
74+ result: Promise.resolve({
75+ accepted: true,
76+ action: "proxy_delivery",
77+ completed: true,
78+ failed: false,
79+ reason: null,
80+ received_at: nowMs + 50,
81+ request_id: "proxy-dispatch-1",
82+ result: {
83+ actual_count: 1,
84+ desired_count: 1,
85+ drift_count: 0,
86+ failed_count: 0,
87+ ok_count: 1,
88+ platform_count: 1,
89+ restored_count: 0,
90+ skipped_reasons: []
91+ },
92+ results: [],
93+ shell_runtime: [],
94+ target: {
95+ client_id: input.clientId || "firefox-chatgpt",
96+ connection_id: "conn-firefox-chatgpt",
97+ platform: input.platform,
98+ requested_client_id: input.clientId || "firefox-chatgpt",
99+ requested_platform: input.platform
100+ },
101+ type: "browser.proxy_delivery"
102+ }),
103+ type: "browser.proxy_delivery"
104+ };
105+ }
106+ },
107+ now: () => nowMs
108+ });
109+
110+ try {
111+ await artifactStore.insertMessage({
112+ conversationId: "conv_dispatch_success",
113+ id: "msg_dispatch_success",
114+ observedAt: nowMs - 60_000,
115+ platform: "chatgpt",
116+ rawText: "renewal dispatcher success message",
117+ role: "assistant"
118+ });
119+ await artifactStore.upsertLocalConversation({
120+ automationStatus: "auto",
121+ localConversationId: "lc_dispatch_success",
122+ platform: "chatgpt",
123+ updatedAt: nowMs - 30_000
124+ });
125+ await artifactStore.upsertConversationLink({
126+ clientId: "firefox-chatgpt",
127+ linkId: "link_dispatch_success",
128+ localConversationId: "lc_dispatch_success",
129+ observedAt: nowMs - 30_000,
130+ pageTitle: "Dispatch Success",
131+ pageUrl: "https://chatgpt.com/c/conv_dispatch_success",
132+ platform: "chatgpt",
133+ remoteConversationId: "conv_dispatch_success",
134+ routeParams: {
135+ conversationId: "conv_dispatch_success"
136+ },
137+ routePath: "/c/conv_dispatch_success",
138+ routePattern: "/c/:conversationId",
139+ targetId: "tab:17",
140+ targetKind: "browser.proxy_delivery",
141+ targetPayload: {
142+ clientId: "firefox-chatgpt",
143+ conversationId: "conv_dispatch_success",
144+ pageUrl: "https://chatgpt.com/c/conv_dispatch_success",
145+ tabId: 17
146+ }
147+ });
148+ await artifactStore.insertRenewalJob({
149+ jobId: "job_dispatch_success",
150+ localConversationId: "lc_dispatch_success",
151+ messageId: "msg_dispatch_success",
152+ nextAttemptAt: nowMs,
153+ payload: JSON.stringify({
154+ kind: "renewal.message",
155+ sourceMessage: {
156+ id: "msg_dispatch_success"
157+ },
158+ template: "summary_with_link",
159+ text: "[renewal] keepalive",
160+ version: 1
161+ }),
162+ payloadKind: "json",
163+ targetSnapshot: {
164+ stale: true
165+ }
166+ });
167+
168+ const { context, entries } = createTimedJobRunnerContext({
169+ artifactStore,
170+ logDir: logsDir
171+ });
172+ const result = await runner.run(context);
173+ const job = await artifactStore.getRenewalJob("job_dispatch_success");
174+
175+ assert.equal(result.result, "ok");
176+ assert.equal(browserCalls.length, 1);
177+ assert.equal(browserCalls[0].messageText, "[renewal] keepalive");
178+ assert.equal(browserCalls[0].tabId, 17);
179+ assert.equal(job.status, "done");
180+ assert.equal(job.attemptCount, 1);
181+ assert.equal(job.lastError, null);
182+ assert.equal(job.nextAttemptAt, null);
183+ assert.equal(typeof job.finishedAt, "number");
184+ assert.equal(JSON.parse(job.targetSnapshot).target.kind, "browser.proxy_delivery");
185+ assert.ok(entries.find((entry) => entry.stage === "job_attempt_started"));
186+ assert.ok(entries.find((entry) => entry.stage === "job_completed" && entry.result === "attempt_succeeded"));
187+ } finally {
188+ artifactStore.close();
189+ rmSync(rootDir, {
190+ force: true,
191+ recursive: true
192+ });
193+ rmSync(logsDir, {
194+ force: true,
195+ recursive: true
196+ });
197+ }
198+});
199+
200+test("renewal dispatcher defers paused jobs and retries transient proxy failures until failed", async () => {
201+ const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-retry-"));
202+ const stateDir = join(rootDir, "state");
203+ const artifactStore = new ArtifactStore({
204+ artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
205+ databasePath: join(stateDir, ARTIFACT_DB_FILENAME)
206+ });
207+ let nowMs = Date.UTC(2026, 2, 30, 13, 0, 0);
208+ const browserCalls = [];
209+ const retryRunner = createRenewalDispatcherRunner({
210+ browserBridge: {
211+ proxyDelivery(input) {
212+ browserCalls.push(input);
213+ return {
214+ clientId: input.clientId || "firefox-claude",
215+ connectionId: "conn-firefox-claude",
216+ dispatchedAt: nowMs,
217+ requestId: `proxy-retry-${browserCalls.length}`,
218+ result: Promise.resolve({
219+ accepted: false,
220+ action: "proxy_delivery",
221+ completed: true,
222+ failed: true,
223+ reason: "no_active_client",
224+ received_at: nowMs + 25,
225+ request_id: `proxy-retry-${browserCalls.length}`,
226+ result: {
227+ actual_count: 0,
228+ desired_count: 1,
229+ drift_count: 0,
230+ failed_count: 1,
231+ ok_count: 0,
232+ platform_count: 1,
233+ restored_count: 0,
234+ skipped_reasons: ["no_active_client"]
235+ },
236+ results: [],
237+ shell_runtime: [],
238+ target: {
239+ client_id: input.clientId || "firefox-claude",
240+ connection_id: "conn-firefox-claude",
241+ platform: input.platform,
242+ requested_client_id: input.clientId || "firefox-claude",
243+ requested_platform: input.platform
244+ },
245+ type: "browser.proxy_delivery"
246+ }),
247+ type: "browser.proxy_delivery"
248+ };
249+ }
250+ },
251+ now: () => nowMs,
252+ retryBaseDelayMs: 1,
253+ retryMaxDelayMs: 1
254+ });
255+
256+ try {
257+ await artifactStore.insertMessage({
258+ conversationId: "conv_dispatch_paused",
259+ id: "msg_dispatch_paused",
260+ observedAt: nowMs - 60_000,
261+ platform: "claude",
262+ rawText: "renewal dispatcher paused message",
263+ role: "assistant"
264+ });
265+ await artifactStore.insertMessage({
266+ conversationId: "conv_dispatch_retry",
267+ id: "msg_dispatch_retry",
268+ observedAt: nowMs - 55_000,
269+ platform: "claude",
270+ rawText: "renewal dispatcher retry message",
271+ role: "assistant"
272+ });
273+ await artifactStore.upsertLocalConversation({
274+ automationStatus: "paused",
275+ localConversationId: "lc_dispatch_paused",
276+ pausedAt: nowMs - 5_000,
277+ platform: "claude"
278+ });
279+ await artifactStore.upsertLocalConversation({
280+ automationStatus: "auto",
281+ localConversationId: "lc_dispatch_retry",
282+ platform: "claude"
283+ });
284+ await artifactStore.upsertConversationLink({
285+ clientId: "firefox-claude",
286+ linkId: "link_dispatch_paused",
287+ localConversationId: "lc_dispatch_paused",
288+ observedAt: nowMs - 20_000,
289+ pageTitle: "Paused Renewal",
290+ pageUrl: "https://claude.ai/chat/conv_dispatch_paused",
291+ platform: "claude",
292+ remoteConversationId: "conv_dispatch_paused",
293+ routeParams: {
294+ conversationId: "conv_dispatch_paused"
295+ },
296+ routePath: "/chat/conv_dispatch_paused",
297+ routePattern: "/chat/:conversationId",
298+ targetId: "tab:11",
299+ targetKind: "browser.proxy_delivery",
300+ targetPayload: {
301+ clientId: "firefox-claude",
302+ conversationId: "conv_dispatch_paused",
303+ pageUrl: "https://claude.ai/chat/conv_dispatch_paused",
304+ tabId: 11
305+ }
306+ });
307+ await artifactStore.upsertConversationLink({
308+ clientId: "firefox-claude",
309+ linkId: "link_dispatch_retry",
310+ localConversationId: "lc_dispatch_retry",
311+ observedAt: nowMs - 20_000,
312+ pageTitle: "Retry Renewal",
313+ pageUrl: "https://claude.ai/chat/conv_dispatch_retry",
314+ platform: "claude",
315+ remoteConversationId: "conv_dispatch_retry",
316+ routeParams: {
317+ conversationId: "conv_dispatch_retry"
318+ },
319+ routePath: "/chat/conv_dispatch_retry",
320+ routePattern: "/chat/:conversationId",
321+ targetId: "tab:12",
322+ targetKind: "browser.proxy_delivery",
323+ targetPayload: {
324+ clientId: "firefox-claude",
325+ conversationId: "conv_dispatch_retry",
326+ pageUrl: "https://claude.ai/chat/conv_dispatch_retry",
327+ tabId: 12
328+ }
329+ });
330+ await artifactStore.insertRenewalJob({
331+ jobId: "job_dispatch_paused",
332+ localConversationId: "lc_dispatch_paused",
333+ messageId: "msg_dispatch_paused",
334+ nextAttemptAt: nowMs,
335+ payload: "[renewal] paused",
336+ payloadKind: "text"
337+ });
338+ await artifactStore.insertRenewalJob({
339+ jobId: "job_dispatch_retry",
340+ localConversationId: "lc_dispatch_retry",
341+ maxAttempts: 2,
342+ messageId: "msg_dispatch_retry",
343+ nextAttemptAt: nowMs,
344+ payload: "[renewal] retry",
345+ payloadKind: "text"
346+ });
347+
348+ const pausedContext = createTimedJobRunnerContext({
349+ artifactStore,
350+ config: {
351+ intervalMs: 5_000,
352+ maxMessagesPerTick: 10,
353+ maxTasksPerTick: 10,
354+ settleDelayMs: 0
355+ }
356+ });
357+ const firstResult = await retryRunner.run(pausedContext.context);
358+ const pausedJob = await artifactStore.getRenewalJob("job_dispatch_paused");
359+ const retryAfterFirstAttempt = await artifactStore.getRenewalJob("job_dispatch_retry");
360+
361+ assert.equal(firstResult.result, "ok");
362+ assert.equal(pausedJob.status, "pending");
363+ assert.equal(pausedJob.attemptCount, 0);
364+ assert.equal(pausedJob.nextAttemptAt, nowMs + 10_000);
365+ assert.equal(retryAfterFirstAttempt.status, "pending");
366+ assert.equal(retryAfterFirstAttempt.attemptCount, 1);
367+ assert.equal(retryAfterFirstAttempt.lastError, "no_active_client");
368+ assert.equal(retryAfterFirstAttempt.nextAttemptAt, nowMs + 1);
369+ assert.equal(browserCalls.length, 1);
370+ assert.ok(
371+ pausedContext.entries.find((entry) => entry.stage === "job_deferred" && entry.result === "automation_paused")
372+ );
373+ assert.ok(
374+ pausedContext.entries.find((entry) => entry.stage === "job_retry_scheduled" && entry.result === "no_active_client")
375+ );
376+
377+ nowMs = retryAfterFirstAttempt.nextAttemptAt;
378+ const secondContext = createTimedJobRunnerContext({
379+ artifactStore
380+ });
381+ const secondResult = await retryRunner.run(secondContext.context);
382+ const retryAfterSecondAttempt = await artifactStore.getRenewalJob("job_dispatch_retry");
383+
384+ assert.equal(secondResult.result, "ok");
385+ assert.equal(retryAfterSecondAttempt.status, "failed");
386+ assert.equal(retryAfterSecondAttempt.attemptCount, 2);
387+ assert.equal(retryAfterSecondAttempt.lastError, "no_active_client");
388+ assert.equal(retryAfterSecondAttempt.nextAttemptAt, null);
389+ assert.equal(browserCalls.length, 2);
390+ assert.ok(
391+ secondContext.entries.find((entry) => entry.stage === "job_failed" && entry.result === "no_active_client")
392+ );
393+ } finally {
394+ artifactStore.close();
395+ rmSync(rootDir, {
396+ force: true,
397+ recursive: true
398+ });
399+ }
400+});
401+
402 test("ConductorTimedJobs keeps standby runners idle and clears interval handles on stop", async () => {
403 const logsDir = mkdtempSync(join(tmpdir(), "baa-timed-jobs-standby-"));
404 const intervalScheduler = createManualIntervalScheduler();
405@@ -6374,6 +6757,125 @@ test("ConductorRuntime persists renewal conversation links from browser.final_me
406 }
407 });
408
409+test("ConductorRuntime exposes renewal jobs APIs and registers the renewal dispatcher runner", async () => {
410+ const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-jobs-api-"));
411+ const runtime = new ConductorRuntime(
412+ {
413+ nodeId: "mini-main",
414+ host: "mini",
415+ role: "primary",
416+ controlApiBase: "https://control.example.test",
417+ localApiBase: "http://127.0.0.1:0",
418+ sharedToken: "replace-me",
419+ paths: {
420+ runsDir: "/tmp/runs",
421+ stateDir
422+ }
423+ },
424+ {
425+ autoStartLoops: false,
426+ now: () => 300
427+ }
428+ );
429+ const nowMs = Date.UTC(2026, 2, 30, 14, 0, 0);
430+
431+ try {
432+ const snapshot = await runtime.start();
433+ const baseUrl = snapshot.controlApi.localApiBase;
434+ const artifactStore = runtime["artifactStore"];
435+ const timedJobs = runtime["timedJobs"];
436+
437+ assert.ok(timedJobs.getRegisteredRunnerNames().includes("renewal.dispatcher"));
438+
439+ await artifactStore.insertMessage({
440+ conversationId: "conv-renewal-job-api",
441+ id: "msg-renewal-job-api",
442+ observedAt: nowMs - 30_000,
443+ platform: "chatgpt",
444+ rawText: "renewal jobs api message",
445+ role: "assistant"
446+ });
447+ await artifactStore.upsertLocalConversation({
448+ automationStatus: "auto",
449+ localConversationId: "lc-renewal-job-api",
450+ platform: "chatgpt"
451+ });
452+ await artifactStore.upsertConversationLink({
453+ clientId: "firefox-chatgpt",
454+ linkId: "link-renewal-job-api",
455+ localConversationId: "lc-renewal-job-api",
456+ observedAt: nowMs - 20_000,
457+ pageTitle: "Renewal Jobs API",
458+ pageUrl: "https://chatgpt.com/c/conv-renewal-job-api",
459+ platform: "chatgpt",
460+ remoteConversationId: "conv-renewal-job-api",
461+ routeParams: {
462+ conversationId: "conv-renewal-job-api"
463+ },
464+ routePath: "/c/conv-renewal-job-api",
465+ routePattern: "/c/:conversationId",
466+ targetId: "tab:44",
467+ targetKind: "browser.proxy_delivery",
468+ targetPayload: {
469+ clientId: "firefox-chatgpt",
470+ conversationId: "conv-renewal-job-api",
471+ pageUrl: "https://chatgpt.com/c/conv-renewal-job-api",
472+ tabId: 44
473+ }
474+ });
475+ await artifactStore.insertRenewalJob({
476+ jobId: "job-renewal-job-api",
477+ localConversationId: "lc-renewal-job-api",
478+ messageId: "msg-renewal-job-api",
479+ nextAttemptAt: nowMs,
480+ payload: JSON.stringify({
481+ kind: "renewal.message",
482+ sourceMessage: {
483+ id: "msg-renewal-job-api"
484+ },
485+ template: "summary_with_link",
486+ text: "[renewal] api payload",
487+ version: 1
488+ }),
489+ payloadKind: "json",
490+ targetSnapshot: {
491+ target: {
492+ id: "tab:44",
493+ kind: "browser.proxy_delivery",
494+ payload: {
495+ clientId: "firefox-chatgpt",
496+ tabId: 44
497+ }
498+ }
499+ }
500+ });
501+
502+ const listResponse = await fetch(
503+ `${baseUrl}/v1/renewal/jobs?status=pending&local_conversation_id=lc-renewal-job-api`
504+ );
505+ assert.equal(listResponse.status, 200);
506+ const listPayload = await listResponse.json();
507+ assert.equal(listPayload.data.count, 1);
508+ assert.equal(listPayload.data.jobs[0].job_id, "job-renewal-job-api");
509+ assert.equal(listPayload.data.jobs[0].payload_text, "[renewal] api payload");
510+ assert.equal(listPayload.data.jobs[0].target_snapshot.target.kind, "browser.proxy_delivery");
511+
512+ const readResponse = await fetch(`${baseUrl}/v1/renewal/jobs/job-renewal-job-api`);
513+ assert.equal(readResponse.status, 200);
514+ const readPayload = await readResponse.json();
515+ assert.equal(readPayload.data.job_id, "job-renewal-job-api");
516+ assert.equal(readPayload.data.local_conversation_id, "lc-renewal-job-api");
517+ assert.equal(readPayload.data.status, "pending");
518+ assert.equal(readPayload.data.message_id, "msg-renewal-job-api");
519+ } finally {
520+ await runtime.stop();
521+ rmSync(stateDir, {
522+ force: true,
523+ recursive: true
524+ });
525+ }
526+});
527+
528 test("ConductorRuntime registers renewal projector, projects auto messages once, and keeps cursor across restart", async () => {
529 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-projector-runtime-"));
530 const logsDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-projector-runtime-logs-"));
+9,
-0
1@@ -49,6 +49,7 @@ import {
2 ConductorLocalControlPlane,
3 resolveDefaultConductorStateDir
4 } from "./local-control-plane.js";
5+import { createRenewalDispatcherRunner } from "./renewal/dispatcher.js";
6 import { createRenewalProjectorRunner } from "./renewal/projector.js";
7 import { ConductorTimedJobs } from "./timed-jobs/index.js";
8
9@@ -73,6 +74,7 @@ export {
10 export { handleConductorHttpRequest } from "./local-api.js";
11 export * from "./artifacts/index.js";
12 export * from "./instructions/index.js";
13+export * from "./renewal/dispatcher.js";
14 export * from "./renewal/projector.js";
15 export * from "./timed-jobs/index.js";
16
17@@ -2346,6 +2348,13 @@ export class ConductorRuntime {
18 ingestLogDir,
19 pluginDiagnosticLogDir
20 );
21+ this.timedJobs.registerRunner(
22+ createRenewalDispatcherRunner({
23+ browserBridge:
24+ this.localApiServer?.getFirefoxBridgeService() as unknown as BrowserBridgeController ?? null,
25+ now: () => this.now() * 1000
26+ })
27+ );
28
29 // D1 sync worker — silently skipped when env vars are not set.
30 this.d1SyncWorker = createD1SyncWorker({
+145,
-2
1@@ -6,7 +6,9 @@ import {
2 buildArtifactPublicUrl,
3 getArtifactContentType,
4 type ArtifactStore,
5- type ConversationAutomationStatus
6+ type ConversationAutomationStatus,
7+ type RenewalJobRecord,
8+ type RenewalJobStatus
9 } from "../../../packages/artifact-db/dist/index.js";
10 import {
11 AUTOMATION_STATE_KEY,
12@@ -201,6 +203,7 @@ const MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS = 60_000;
13 const MAX_BROWSER_WS_RECONNECT_REPEAT_COUNT = 20;
14 const MAX_BROWSER_WS_RECONNECT_REPEAT_INTERVAL_MS = 60_000;
15 const RENEWAL_AUTOMATION_STATUS_SET = new Set<ConversationAutomationStatus>(["manual", "auto", "paused"]);
16+const RENEWAL_JOB_STATUS_SET = new Set<RenewalJobStatus>(["pending", "running", "done", "failed"]);
17
18 type LocalApiRouteMethod = "GET" | "POST";
19 type LocalApiRouteKind = "probe" | "read" | "write";
20@@ -783,6 +786,20 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
21 pathPattern: "/v1/renewal/links",
22 summary: "查询本地对话关联表(分页、按 platform/conversation/client 过滤)"
23 },
24+ {
25+ id: "renewal.jobs.list",
26+ kind: "read",
27+ method: "GET",
28+ pathPattern: "/v1/renewal/jobs",
29+ summary: "查询续命任务列表(分页、按状态/对话/message 过滤)"
30+ },
31+ {
32+ id: "renewal.jobs.read",
33+ kind: "read",
34+ method: "GET",
35+ pathPattern: "/v1/renewal/jobs/:job_id",
36+ summary: "读取单个续命任务详情"
37+ },
38 {
39 id: "renewal.conversations.manual",
40 kind: "write",
41@@ -1822,6 +1839,30 @@ function readOptionalRenewalAutomationStatusQuery(
42 return rawValue as ConversationAutomationStatus;
43 }
44
45+function readOptionalRenewalJobStatusQuery(
46+ url: URL,
47+ ...fieldNames: string[]
48+): RenewalJobStatus | undefined {
49+ const rawValue = readOptionalQueryString(url, ...fieldNames);
50+
51+ if (rawValue == null) {
52+ return undefined;
53+ }
54+
55+ if (!RENEWAL_JOB_STATUS_SET.has(rawValue as RenewalJobStatus)) {
56+ throw new LocalApiHttpError(
57+ 400,
58+ "invalid_request",
59+ `Query parameter "${fieldNames[0] ?? "status"}" must be one of pending, running, done, failed.`,
60+ {
61+ field: fieldNames[0] ?? "status"
62+ }
63+ );
64+ }
65+
66+ return rawValue as RenewalJobStatus;
67+}
68+
69 function readOptionalNumberField(body: JsonObject, fieldName: string): number | undefined {
70 const value = body[fieldName];
71
72@@ -4363,7 +4404,9 @@ function routeBelongsToSurface(
73 "artifact.sessions.latest",
74 "renewal.conversations.list",
75 "renewal.conversations.read",
76- "renewal.links.list"
77+ "renewal.links.list",
78+ "renewal.jobs.list",
79+ "renewal.jobs.read"
80 ].includes(route.id);
81 }
82
83@@ -4383,6 +4426,8 @@ function routeBelongsToSurface(
84 "renewal.conversations.list",
85 "renewal.conversations.read",
86 "renewal.links.list",
87+ "renewal.jobs.list",
88+ "renewal.jobs.read",
89 "renewal.conversations.manual",
90 "renewal.conversations.auto",
91 "renewal.conversations.paused",
92@@ -7151,6 +7196,39 @@ function buildRenewalConversationData(
93 });
94 }
95
96+function buildRenewalJobData(job: RenewalJobRecord): JsonObject {
97+ const parsedPayload = job.payloadKind === "json" ? tryParseJson(job.payload) : null;
98+ const parsedTargetSnapshot = tryParseJson(job.targetSnapshot);
99+ const payloadText =
100+ typeof parsedPayload === "object"
101+ && parsedPayload != null
102+ && "text" in parsedPayload
103+ && typeof parsedPayload.text === "string"
104+ ? parsedPayload.text
105+ : (job.payloadKind === "text" ? job.payload : undefined);
106+
107+ return compactJsonObject({
108+ job_id: job.jobId,
109+ local_conversation_id: job.localConversationId,
110+ message_id: job.messageId,
111+ status: job.status,
112+ payload_kind: job.payloadKind,
113+ payload: parsedPayload ?? job.payload,
114+ payload_text: payloadText ?? undefined,
115+ target_snapshot: parsedTargetSnapshot ?? job.targetSnapshot ?? undefined,
116+ attempt_count: job.attemptCount,
117+ max_attempts: job.maxAttempts,
118+ next_attempt_at: job.nextAttemptAt ?? undefined,
119+ last_attempt_at: job.lastAttemptAt ?? undefined,
120+ last_error: job.lastError ?? undefined,
121+ log_path: job.logPath ?? undefined,
122+ started_at: job.startedAt ?? undefined,
123+ finished_at: job.finishedAt ?? undefined,
124+ created_at: job.createdAt,
125+ updated_at: job.updatedAt
126+ });
127+}
128+
129 async function handleRenewalConversationsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
130 const store = requireArtifactStore(context.artifactStore);
131 const platform = readOptionalQueryString(context.url, "platform");
132@@ -7252,6 +7330,67 @@ async function handleRenewalLinksList(context: LocalApiRequestContext): Promise<
133 });
134 }
135
136+async function handleRenewalJobsList(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
137+ const store = requireArtifactStore(context.artifactStore);
138+ const localConversationId = readOptionalQueryString(
139+ context.url,
140+ "local_conversation_id",
141+ "localConversationId"
142+ );
143+ const messageId = readOptionalQueryString(context.url, "message_id", "messageId");
144+ const status = readOptionalRenewalJobStatusQuery(context.url, "status");
145+ const limit = readPositiveIntegerQuery(context.url, "limit", ARTIFACT_DEFAULT_SESSION_LIMIT, ARTIFACT_LIST_MAX_LIMIT);
146+ const offset = readNonNegativeIntegerQuery(context.url, "offset", 0);
147+ const jobs = await store.listRenewalJobs({
148+ limit,
149+ localConversationId,
150+ messageId,
151+ offset,
152+ status
153+ });
154+
155+ return buildSuccessEnvelope(context.requestId, 200, {
156+ count: jobs.length,
157+ filters: compactJsonObject({
158+ limit,
159+ local_conversation_id: localConversationId ?? undefined,
160+ message_id: messageId ?? undefined,
161+ offset,
162+ status: status ?? undefined
163+ }),
164+ jobs: jobs.map((entry) => buildRenewalJobData(entry))
165+ });
166+}
167+
168+async function handleRenewalJobRead(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
169+ const store = requireArtifactStore(context.artifactStore);
170+ const jobId = context.params.job_id;
171+
172+ if (!jobId) {
173+ throw new LocalApiHttpError(
174+ 400,
175+ "invalid_request",
176+ 'Route parameter "job_id" is required.'
177+ );
178+ }
179+
180+ const job = await store.getRenewalJob(jobId);
181+
182+ if (job == null) {
183+ throw new LocalApiHttpError(
184+ 404,
185+ "not_found",
186+ `Renewal job "${jobId}" was not found.`,
187+ {
188+ resource: "renewal_job",
189+ resource_id: jobId
190+ }
191+ );
192+ }
193+
194+ return buildSuccessEnvelope(context.requestId, 200, buildRenewalJobData(job));
195+}
196+
197 async function handleRenewalConversationMutation(
198 context: LocalApiRequestContext,
199 automationStatus: ConversationAutomationStatus
200@@ -7438,6 +7577,10 @@ async function dispatchBusinessRoute(
201 return handleRenewalConversationRead(context);
202 case "renewal.links.list":
203 return handleRenewalLinksList(context);
204+ case "renewal.jobs.list":
205+ return handleRenewalJobsList(context);
206+ case "renewal.jobs.read":
207+ return handleRenewalJobRead(context);
208 case "renewal.conversations.manual":
209 return handleRenewalConversationMutation(context, "manual");
210 case "renewal.conversations.auto":
1@@ -0,0 +1,807 @@
2+import { join } from "node:path";
3+
4+import type {
5+ ArtifactStore,
6+ ConversationLinkRecord,
7+ LocalConversationRecord,
8+ RenewalJobPayloadKind,
9+ RenewalJobRecord
10+} from "../../../../packages/artifact-db/dist/index.js";
11+
12+import type {
13+ BrowserBridgeActionDispatch,
14+ BrowserBridgeActionResultSnapshot,
15+ BrowserBridgeController
16+} from "../browser-types.js";
17+import type { TimedJobRunner, TimedJobRunnerResult, TimedJobTickContext } from "../timed-jobs/index.js";
18+
19+import {
20+ buildRenewalTargetSnapshot,
21+ type RenewalProjectorPayload,
22+ type RenewalProjectorTargetSnapshot
23+} from "./projector.js";
24+
25+const DEFAULT_EXECUTION_TIMEOUT_MS = 20_000;
26+const DEFAULT_RECHECK_DELAY_MS = 10_000;
27+const DEFAULT_RETRY_BASE_DELAY_MS = 30_000;
28+const DEFAULT_RETRY_MAX_DELAY_MS = 5 * 60_000;
29+const PROXY_DELIVERY_TARGET_KIND = "browser.proxy_delivery";
30+const RUNNER_NAME = "renewal.dispatcher";
31+const TAB_TARGET_ID_PATTERN = /^tab:(\d+)$/u;
32+
33+type RenewalDispatcherJobResult =
34+ | "attempt_failed"
35+ | "attempt_started"
36+ | "attempt_succeeded"
37+ | "automation_manual"
38+ | "automation_paused"
39+ | "failed"
40+ | "idle"
41+ | "missing_active_link"
42+ | "no_artifact_store"
43+ | "no_browser_bridge"
44+ | "ok"
45+ | "retry_scheduled";
46+
47+type RenewalJobTerminalReason =
48+ | "invalid_payload"
49+ | "missing_local_conversation"
50+ | "missing_payload_text";
51+
52+interface RenewalDispatchPayload {
53+ structured: RenewalProjectorPayload | null;
54+ text: string;
55+}
56+
57+interface RenewalDispatchTarget {
58+ clientId: string | null;
59+ conversationId: string | null;
60+ organizationId: string | null;
61+ pageTitle: string | null;
62+ pageUrl: string | null;
63+ platform: string;
64+ tabId: number;
65+}
66+
67+interface RenewalDispatchContext {
68+ conversation: LocalConversationRecord;
69+ job: RenewalJobRecord;
70+ link: ConversationLinkRecord;
71+ target: RenewalDispatchTarget;
72+ targetSnapshot: RenewalProjectorTargetSnapshot;
73+}
74+
75+interface RenewalDispatchOutcome {
76+ dispatch: BrowserBridgeActionDispatch;
77+ result: BrowserBridgeActionResultSnapshot;
78+}
79+
80+interface RenewalDispatcherRunnerOptions {
81+ browserBridge: BrowserBridgeController | null;
82+ executionTimeoutMs?: number;
83+ now?: () => number;
84+ recheckDelayMs?: number;
85+ retryBaseDelayMs?: number;
86+ retryMaxDelayMs?: number;
87+}
88+
89+export function createRenewalDispatcherRunner(
90+ options: RenewalDispatcherRunnerOptions
91+): TimedJobRunner {
92+ return {
93+ name: RUNNER_NAME,
94+ async run(context) {
95+ return runRenewalDispatcher(context, options);
96+ }
97+ };
98+}
99+
100+export async function runRenewalDispatcher(
101+ context: TimedJobTickContext,
102+ options: RenewalDispatcherRunnerOptions
103+): Promise<TimedJobRunnerResult> {
104+ const artifactStore = context.artifactStore;
105+
106+ if (artifactStore == null) {
107+ context.log({
108+ stage: "scan_skipped",
109+ result: "no_artifact_store"
110+ });
111+ return buildDispatcherSummary("no_artifact_store");
112+ }
113+
114+ if (options.browserBridge == null) {
115+ context.log({
116+ stage: "scan_skipped",
117+ result: "no_browser_bridge"
118+ });
119+ return buildDispatcherSummary("no_browser_bridge");
120+ }
121+
122+ const now = options.now ?? (() => Date.now());
123+ const nowMs = now();
124+ const dueJobs = await artifactStore.listRenewalJobs({
125+ limit: context.maxTasksPerTick,
126+ nextAttemptAtLte: nowMs,
127+ status: "pending"
128+ });
129+
130+ context.log({
131+ stage: "scan_window",
132+ result: "ok",
133+ details: {
134+ due_before: nowMs,
135+ limit: context.maxTasksPerTick
136+ }
137+ });
138+
139+ if (dueJobs.length === 0) {
140+ context.log({
141+ stage: "scan_completed",
142+ result: "idle",
143+ details: {
144+ due_jobs: 0,
145+ failed_jobs: 0,
146+ retried_jobs: 0,
147+ skipped_jobs: 0,
148+ successful_jobs: 0
149+ }
150+ });
151+ return buildDispatcherSummary("idle");
152+ }
153+
154+ let failedJobs = 0;
155+ let retriedJobs = 0;
156+ let skippedJobs = 0;
157+ let successfulJobs = 0;
158+
159+ for (const job of dueJobs) {
160+ const dispatchContext = await resolveDispatchContext(artifactStore, job);
161+
162+ if (dispatchContext.conversation == null) {
163+ failedJobs += 1;
164+ await markJobFailed(artifactStore, job, {
165+ attemptCount: job.attemptCount + 1,
166+ lastError: "missing_local_conversation",
167+ logPath: resolveJobLogPath(job.logPath, context.logDir, nowMs),
168+ targetSnapshot: dispatchContext.targetSnapshot
169+ });
170+ context.log({
171+ stage: "job_failed",
172+ result: "missing_local_conversation",
173+ details: {
174+ attempt_count: job.attemptCount + 1,
175+ job_id: job.jobId,
176+ local_conversation_id: job.localConversationId,
177+ message_id: job.messageId
178+ }
179+ });
180+ continue;
181+ }
182+
183+ if (dispatchContext.conversation.automationStatus !== "auto") {
184+ skippedJobs += 1;
185+ const nextAttemptAt = nowMs + resolvePositiveInteger(
186+ options.recheckDelayMs,
187+ Math.max(DEFAULT_RECHECK_DELAY_MS, context.config.intervalMs)
188+ );
189+ await artifactStore.updateRenewalJob({
190+ jobId: job.jobId,
191+ logPath: resolveJobLogPath(job.logPath, context.logDir, nowMs),
192+ nextAttemptAt,
193+ targetSnapshot: dispatchContext.targetSnapshot,
194+ updatedAt: nowMs
195+ });
196+ context.log({
197+ stage: "job_deferred",
198+ result:
199+ dispatchContext.conversation.automationStatus === "paused"
200+ ? "automation_paused"
201+ : "automation_manual",
202+ details: {
203+ automation_status: dispatchContext.conversation.automationStatus,
204+ job_id: job.jobId,
205+ local_conversation_id: job.localConversationId,
206+ message_id: job.messageId,
207+ next_attempt_at: nextAttemptAt
208+ }
209+ });
210+ continue;
211+ }
212+
213+ if (dispatchContext.link == null || dispatchContext.target == null) {
214+ const attempts = job.attemptCount + 1;
215+ const failureResult = await applyFailureOutcome(artifactStore, job, {
216+ attemptCount: attempts,
217+ errorMessage: dispatchContext.link == null ? "missing_active_link" : "route_unavailable",
218+ logDir: context.logDir,
219+ now: nowMs,
220+ retryBaseDelayMs: options.retryBaseDelayMs,
221+ retryMaxDelayMs: options.retryMaxDelayMs,
222+ targetSnapshot: dispatchContext.targetSnapshot
223+ });
224+
225+ if (failureResult.status === "failed") {
226+ failedJobs += 1;
227+ context.log({
228+ stage: "job_failed",
229+ result: failureResult.result,
230+ details: {
231+ attempt_count: attempts,
232+ job_id: job.jobId,
233+ message_id: job.messageId
234+ }
235+ });
236+ } else {
237+ retriedJobs += 1;
238+ context.log({
239+ stage: "job_retry_scheduled",
240+ result: failureResult.result,
241+ details: {
242+ attempt_count: attempts,
243+ job_id: job.jobId,
244+ message_id: job.messageId,
245+ next_attempt_at: failureResult.nextAttemptAt
246+ }
247+ });
248+ }
249+ continue;
250+ }
251+
252+ const payload = normalizeDispatchPayload(job.payload, job.payloadKind);
253+
254+ if (payload.text == null) {
255+ const errorMessage = payload.error ?? "invalid_payload";
256+ failedJobs += 1;
257+ await markJobFailed(artifactStore, job, {
258+ attemptCount: job.attemptCount + 1,
259+ lastError: errorMessage,
260+ logPath: resolveJobLogPath(job.logPath, context.logDir, nowMs),
261+ targetSnapshot: dispatchContext.targetSnapshot
262+ });
263+ context.log({
264+ stage: "job_failed",
265+ result: errorMessage,
266+ details: {
267+ attempt_count: job.attemptCount + 1,
268+ job_id: job.jobId,
269+ message_id: job.messageId
270+ }
271+ });
272+ continue;
273+ }
274+
275+ const runningJob = await artifactStore.updateRenewalJob({
276+ finishedAt: null,
277+ jobId: job.jobId,
278+ lastAttemptAt: nowMs,
279+ lastError: null,
280+ logPath: resolveJobLogPath(job.logPath, context.logDir, nowMs),
281+ nextAttemptAt: null,
282+ startedAt: nowMs,
283+ status: "running",
284+ targetSnapshot: dispatchContext.targetSnapshot,
285+ updatedAt: nowMs
286+ });
287+ context.log({
288+ stage: "job_attempt_started",
289+ result: "attempt_started",
290+ details: {
291+ attempt_count: job.attemptCount + 1,
292+ client_id: dispatchContext.target.clientId,
293+ job_id: job.jobId,
294+ local_conversation_id: job.localConversationId,
295+ message_id: job.messageId,
296+ tab_id: dispatchContext.target.tabId
297+ }
298+ });
299+
300+ try {
301+ const delivery = await dispatchRenewalJob(options.browserBridge, {
302+ assistantMessageId: job.messageId,
303+ messageText: payload.text,
304+ target: dispatchContext.target,
305+ timeoutMs: resolvePositiveInteger(options.executionTimeoutMs, DEFAULT_EXECUTION_TIMEOUT_MS)
306+ });
307+ const finishedAt = now();
308+ const attemptCount = job.attemptCount + 1;
309+
310+ await artifactStore.updateRenewalJob({
311+ attemptCount,
312+ finishedAt,
313+ jobId: job.jobId,
314+ lastAttemptAt: nowMs,
315+ lastError: null,
316+ logPath: resolveJobLogPath(job.logPath, context.logDir, nowMs),
317+ nextAttemptAt: null,
318+ startedAt: runningJob.startedAt ?? nowMs,
319+ status: "done",
320+ targetSnapshot: dispatchContext.targetSnapshot,
321+ updatedAt: finishedAt
322+ });
323+ successfulJobs += 1;
324+ context.log({
325+ stage: "job_completed",
326+ result: "attempt_succeeded",
327+ details: {
328+ attempt_count: attemptCount,
329+ client_id: delivery.dispatch.clientId,
330+ connection_id: delivery.dispatch.connectionId,
331+ job_id: job.jobId,
332+ message_id: job.messageId,
333+ proxy_request_id: delivery.dispatch.requestId,
334+ received_at: delivery.result.received_at
335+ }
336+ });
337+ } catch (error) {
338+ const errorMessage = toErrorMessage(error);
339+ const attempts = job.attemptCount + 1;
340+ const failureResult = await applyFailureOutcome(artifactStore, job, {
341+ attemptCount: attempts,
342+ errorMessage,
343+ logDir: context.logDir,
344+ now: now(),
345+ retryBaseDelayMs: options.retryBaseDelayMs,
346+ retryMaxDelayMs: options.retryMaxDelayMs,
347+ targetSnapshot: dispatchContext.targetSnapshot
348+ });
349+
350+ if (failureResult.status === "failed") {
351+ failedJobs += 1;
352+ context.log({
353+ stage: "job_failed",
354+ result: failureResult.result,
355+ details: {
356+ attempt_count: attempts,
357+ job_id: job.jobId,
358+ message_id: job.messageId
359+ }
360+ });
361+ } else {
362+ retriedJobs += 1;
363+ context.log({
364+ stage: "job_retry_scheduled",
365+ result: failureResult.result,
366+ details: {
367+ attempt_count: attempts,
368+ job_id: job.jobId,
369+ message_id: job.messageId,
370+ next_attempt_at: failureResult.nextAttemptAt
371+ }
372+ });
373+ }
374+ }
375+ }
376+
377+ context.log({
378+ stage: "scan_completed",
379+ result: "ok",
380+ details: {
381+ due_jobs: dueJobs.length,
382+ failed_jobs: failedJobs,
383+ retried_jobs: retriedJobs,
384+ skipped_jobs: skippedJobs,
385+ successful_jobs: successfulJobs
386+ }
387+ });
388+
389+ return {
390+ details: {
391+ due_jobs: dueJobs.length,
392+ failed_jobs: failedJobs,
393+ retried_jobs: retriedJobs,
394+ skipped_jobs: skippedJobs,
395+ successful_jobs: successfulJobs
396+ },
397+ result: "ok"
398+ };
399+}
400+
401+async function resolveDispatchContext(
402+ artifactStore: Pick<ArtifactStore, "getLocalConversation" | "listConversationLinks">,
403+ job: RenewalJobRecord
404+): Promise<{
405+ conversation: LocalConversationRecord | null;
406+ link: ConversationLinkRecord | null;
407+ target: RenewalDispatchTarget | null;
408+ targetSnapshot: RenewalProjectorTargetSnapshot | null;
409+}> {
410+ const conversation = await artifactStore.getLocalConversation(job.localConversationId);
411+
412+ if (conversation == null) {
413+ return {
414+ conversation: null,
415+ link: null,
416+ target: null,
417+ targetSnapshot: parseRenewalTargetSnapshot(job.targetSnapshot)
418+ };
419+ }
420+
421+ const [link] = await artifactStore.listConversationLinks({
422+ isActive: true,
423+ limit: 1,
424+ localConversationId: job.localConversationId
425+ });
426+ const targetSnapshot = link == null
427+ ? parseRenewalTargetSnapshot(job.targetSnapshot)
428+ : buildRenewalTargetSnapshot(link);
429+
430+ return {
431+ conversation,
432+ link: link ?? null,
433+ target: resolveDispatchTarget(targetSnapshot),
434+ targetSnapshot
435+ };
436+}
437+
438+async function dispatchRenewalJob(
439+ browserBridge: BrowserBridgeController,
440+ input: {
441+ assistantMessageId: string;
442+ messageText: string;
443+ target: RenewalDispatchTarget;
444+ timeoutMs: number;
445+ }
446+): Promise<RenewalDispatchOutcome> {
447+ const dispatch = browserBridge.proxyDelivery({
448+ assistantMessageId: input.assistantMessageId,
449+ clientId: input.target.clientId,
450+ conversationId: input.target.conversationId,
451+ messageText: input.messageText,
452+ organizationId: input.target.organizationId,
453+ pageTitle: input.target.pageTitle,
454+ pageUrl: input.target.pageUrl,
455+ planId: buildRenewalPlanId(input.assistantMessageId),
456+ platform: input.target.platform,
457+ shellPage: false,
458+ tabId: input.target.tabId,
459+ timeoutMs: input.timeoutMs
460+ });
461+ const result = await dispatch.result;
462+
463+ if (result.accepted !== true || result.failed === true) {
464+ throw new Error(normalizeOptionalString(result.reason) ?? "browser proxy delivery failed");
465+ }
466+
467+ return {
468+ dispatch,
469+ result
470+ };
471+}
472+
473+async function applyFailureOutcome(
474+ artifactStore: Pick<ArtifactStore, "updateRenewalJob">,
475+ job: RenewalJobRecord,
476+ input: {
477+ attemptCount: number;
478+ errorMessage: string;
479+ logDir: string | null;
480+ now: number;
481+ retryBaseDelayMs?: number;
482+ retryMaxDelayMs?: number;
483+ targetSnapshot: RenewalProjectorTargetSnapshot | null;
484+ }
485+): Promise<
486+ | {
487+ result: string;
488+ status: "failed";
489+ }
490+ | {
491+ nextAttemptAt: number;
492+ result: string;
493+ status: "pending";
494+ }
495+> {
496+ const resolvedError = normalizeOptionalString(input.errorMessage) ?? "renewal_dispatch_failed";
497+ const retryable = isRetryableFailure(resolvedError);
498+
499+ if (!retryable || input.attemptCount >= job.maxAttempts) {
500+ await markJobFailed(artifactStore, job, {
501+ attemptCount: input.attemptCount,
502+ lastError: resolvedError,
503+ logPath: resolveJobLogPath(job.logPath, input.logDir, input.now),
504+ now: input.now,
505+ targetSnapshot: input.targetSnapshot
506+ });
507+ return {
508+ result: resolvedError,
509+ status: "failed"
510+ };
511+ }
512+
513+ const nextAttemptAt = input.now + computeRetryDelayMs(input.attemptCount, {
514+ retryBaseDelayMs: input.retryBaseDelayMs,
515+ retryMaxDelayMs: input.retryMaxDelayMs
516+ });
517+ await artifactStore.updateRenewalJob({
518+ attemptCount: input.attemptCount,
519+ finishedAt: null,
520+ jobId: job.jobId,
521+ lastAttemptAt: input.now,
522+ lastError: resolvedError,
523+ logPath: resolveJobLogPath(job.logPath, input.logDir, input.now),
524+ nextAttemptAt,
525+ startedAt: null,
526+ status: "pending",
527+ targetSnapshot: input.targetSnapshot,
528+ updatedAt: input.now
529+ });
530+ return {
531+ nextAttemptAt,
532+ result: resolvedError,
533+ status: "pending"
534+ };
535+}
536+
537+async function markJobFailed(
538+ artifactStore: Pick<ArtifactStore, "updateRenewalJob">,
539+ job: RenewalJobRecord,
540+ input: {
541+ attemptCount: number;
542+ lastError: string;
543+ logPath: string | null;
544+ now?: number;
545+ targetSnapshot: RenewalProjectorTargetSnapshot | null;
546+ }
547+): Promise<void> {
548+ const finishedAt = input.now ?? Date.now();
549+
550+ await artifactStore.updateRenewalJob({
551+ attemptCount: input.attemptCount,
552+ finishedAt,
553+ jobId: job.jobId,
554+ lastAttemptAt: finishedAt,
555+ lastError: input.lastError,
556+ logPath: input.logPath,
557+ nextAttemptAt: null,
558+ startedAt: null,
559+ status: "failed",
560+ targetSnapshot: input.targetSnapshot,
561+ updatedAt: finishedAt
562+ });
563+}
564+
565+function buildDispatcherSummary(result: RenewalDispatcherJobResult): TimedJobRunnerResult {
566+ return {
567+ details: {
568+ due_jobs: 0,
569+ failed_jobs: 0,
570+ retried_jobs: 0,
571+ skipped_jobs: 0,
572+ successful_jobs: 0
573+ },
574+ result
575+ };
576+}
577+
578+function buildRenewalPlanId(messageId: string): string {
579+ return `renewal_${sanitizePathSegment(messageId)}_${Date.now()}`;
580+}
581+
582+function computeRetryDelayMs(
583+ attemptCount: number,
584+ options: {
585+ retryBaseDelayMs?: number;
586+ retryMaxDelayMs?: number;
587+ }
588+): number {
589+ const baseDelayMs = resolvePositiveInteger(options.retryBaseDelayMs, DEFAULT_RETRY_BASE_DELAY_MS);
590+ const maxDelayMs = resolvePositiveInteger(options.retryMaxDelayMs, DEFAULT_RETRY_MAX_DELAY_MS);
591+ const exponent = Math.max(0, attemptCount - 1);
592+ return Math.min(maxDelayMs, baseDelayMs * (2 ** exponent));
593+}
594+
595+function normalizeDispatchPayload(
596+ payload: string,
597+ payloadKind: RenewalJobPayloadKind
598+): {
599+ error: RenewalJobTerminalReason | null;
600+ text: string | null;
601+ value: RenewalDispatchPayload | null;
602+} {
603+ if (payloadKind === "text") {
604+ const text = normalizeOptionalString(payload);
605+ return text == null
606+ ? {
607+ error: "missing_payload_text",
608+ text: null,
609+ value: null
610+ }
611+ : {
612+ error: null,
613+ text,
614+ value: {
615+ structured: null,
616+ text
617+ }
618+ };
619+ }
620+
621+ const parsed = parseJsonValue(payload);
622+
623+ if (!isPlainRecord(parsed)) {
624+ return {
625+ error: "invalid_payload",
626+ text: null,
627+ value: null
628+ };
629+ }
630+
631+ const text = normalizeOptionalString(readString(parsed, "text"));
632+
633+ if (text == null) {
634+ return {
635+ error: "missing_payload_text",
636+ text: null,
637+ value: null
638+ };
639+ }
640+
641+ return {
642+ error: null,
643+ text,
644+ value: {
645+ structured: isRenewalProjectorPayload(parsed) ? parsed : null,
646+ text
647+ }
648+ };
649+}
650+
651+function parseRenewalTargetSnapshot(value: string | null): RenewalProjectorTargetSnapshot | null {
652+ const parsed = parseJsonValue(value);
653+ return isRenewalTargetSnapshot(parsed) ? parsed : null;
654+}
655+
656+function resolveDispatchTarget(
657+ snapshot: RenewalProjectorTargetSnapshot | null
658+): RenewalDispatchTarget | null {
659+ if (snapshot == null || snapshot.target.kind !== PROXY_DELIVERY_TARGET_KIND) {
660+ return null;
661+ }
662+
663+ const payload = isPlainRecord(snapshot.target.payload) ? snapshot.target.payload : null;
664+ const shellPage = readBoolean(payload, "shellPage") === true;
665+ const tabId = readPositiveInteger(payload, "tabId") ?? parseTabId(snapshot.target.id);
666+
667+ if (shellPage || tabId == null) {
668+ return null;
669+ }
670+
671+ return {
672+ clientId: normalizeOptionalString(readString(payload, "clientId")) ?? snapshot.clientId,
673+ conversationId: normalizeOptionalString(readString(payload, "conversationId")),
674+ organizationId: normalizeOptionalString(readString(payload, "organizationId")),
675+ pageTitle: normalizeOptionalString(readString(payload, "pageTitle")) ?? snapshot.pageTitle,
676+ pageUrl: normalizeOptionalString(readString(payload, "pageUrl")) ?? snapshot.pageUrl,
677+ platform: snapshot.platform,
678+ tabId
679+ };
680+}
681+
682+function resolveJobLogPath(existing: string | null, logDir: string | null, now: number): string | null {
683+ if (normalizeOptionalString(existing) != null) {
684+ return existing;
685+ }
686+
687+ if (logDir == null) {
688+ return null;
689+ }
690+
691+ return join(logDir, `${new Date(now).toISOString().slice(0, 10)}.jsonl`);
692+}
693+
694+function isRetryableFailure(message: string): boolean {
695+ return ![
696+ "invalid_payload",
697+ "missing_local_conversation",
698+ "missing_payload_text"
699+ ].includes(message);
700+}
701+
702+function isPlainRecord(value: unknown): value is Record<string, unknown> {
703+ return typeof value === "object" && value != null && !Array.isArray(value);
704+}
705+
706+function isRenewalProjectorPayload(value: unknown): value is RenewalProjectorPayload {
707+ return (
708+ isPlainRecord(value)
709+ && value.kind === "renewal.message"
710+ && typeof value.text === "string"
711+ && typeof value.template === "string"
712+ && value.version === 1
713+ && isPlainRecord(value.sourceMessage)
714+ && typeof value.sourceMessage.id === "string"
715+ );
716+}
717+
718+function isRenewalTargetSnapshot(value: unknown): value is RenewalProjectorTargetSnapshot {
719+ return (
720+ isPlainRecord(value)
721+ && typeof value.platform === "string"
722+ && typeof value.linkId === "string"
723+ && isPlainRecord(value.route)
724+ && isPlainRecord(value.target)
725+ && typeof value.target.kind === "string"
726+ );
727+}
728+
729+function normalizeOptionalString(value: string | null | undefined): string | null {
730+ if (value == null) {
731+ return null;
732+ }
733+
734+ const normalized = value.trim();
735+ return normalized === "" ? null : normalized;
736+}
737+
738+function parseJsonValue(value: string | null): unknown {
739+ if (value == null) {
740+ return null;
741+ }
742+
743+ try {
744+ return JSON.parse(value);
745+ } catch {
746+ return null;
747+ }
748+}
749+
750+function parseTabId(value: string | null): number | null {
751+ const normalized = normalizeOptionalString(value);
752+
753+ if (normalized == null) {
754+ return null;
755+ }
756+
757+ const matched = TAB_TARGET_ID_PATTERN.exec(normalized);
758+
759+ if (matched == null) {
760+ return null;
761+ }
762+
763+ const parsed = Number.parseInt(matched[1] ?? "", 10);
764+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
765+}
766+
767+function readBoolean(record: Record<string, unknown> | null, key: string): boolean | null {
768+ if (record == null) {
769+ return null;
770+ }
771+
772+ return typeof record[key] === "boolean" ? record[key] : null;
773+}
774+
775+function readPositiveInteger(record: Record<string, unknown> | null, key: string): number | null {
776+ if (record == null) {
777+ return null;
778+ }
779+
780+ const value = record[key];
781+ return Number.isInteger(value) && Number(value) > 0 ? Number(value) : null;
782+}
783+
784+function readString(record: Record<string, unknown> | null, key: string): string | null {
785+ if (record == null) {
786+ return null;
787+ }
788+
789+ return typeof record[key] === "string" ? record[key] : null;
790+}
791+
792+function resolvePositiveInteger(value: number | undefined, fallback: number): number {
793+ return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
794+}
795+
796+function sanitizePathSegment(value: string): string {
797+ const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-");
798+ const collapsed = normalized.replace(/-+/gu, "-").replace(/^-|-$/gu, "");
799+ return collapsed === "" ? "unknown" : collapsed;
800+}
801+
802+function toErrorMessage(error: unknown): string {
803+ if (error instanceof Error && normalizeOptionalString(error.message) != null) {
804+ return error.message;
805+ }
806+
807+ return String(error);
808+}
1@@ -17,6 +17,7 @@ const DEFAULT_MESSAGE_ROLE = "assistant";
2 const DEFAULT_PAYLOAD_TEMPLATE = "summary_with_link";
3 const DEFAULT_SUMMARY_LIMIT = 200;
4 const RUNNER_NAME = "renewal.projector";
5+const TAB_TARGET_ID_PATTERN = /^tab:(\d+)$/u;
6
7 export type RenewalProjectorSkipReason =
8 | "automation_manual"
9@@ -492,15 +493,18 @@ function hasAvailableRoute(link: ConversationLinkRecord): boolean {
10 return false;
11 }
12
13- if (normalizeOptionalString(link.targetKind) == null) {
14+ if (normalizeOptionalString(link.targetKind) !== "browser.proxy_delivery") {
15 return false;
16 }
17
18- if (
19- normalizeOptionalString(link.targetId) == null
20- && parseJsonRecord(link.targetPayload) == null
21- && normalizeOptionalString(link.pageUrl) == null
22- ) {
23+ const targetPayload = parseJsonRecord(link.targetPayload);
24+ const shellPage = targetPayload?.shellPage === true;
25+ const tabId =
26+ (typeof targetPayload?.tabId === "number" && Number.isInteger(targetPayload.tabId) && targetPayload.tabId > 0)
27+ ? targetPayload.tabId
28+ : parseTargetTabId(link.targetId);
29+
30+ if (shellPage || tabId == null) {
31 return false;
32 }
33
34@@ -573,6 +577,23 @@ function parseJsonRecord(value: string | null): Record<string, unknown> | null {
35 return isPlainRecord(parsed) ? parsed : null;
36 }
37
38+function parseTargetTabId(value: string | null): number | null {
39+ const normalized = normalizeOptionalString(value);
40+
41+ if (normalized == null) {
42+ return null;
43+ }
44+
45+ const matched = TAB_TARGET_ID_PATTERN.exec(normalized);
46+
47+ if (matched == null) {
48+ return null;
49+ }
50+
51+ const parsed = Number.parseInt(matched[1] ?? "", 10);
52+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
53+}
54+
55 function parseJsonValue(value: string | null): unknown {
56 if (value == null) {
57 return null;
+9,
-0
1@@ -55,6 +55,11 @@
2 | --- | --- | --- |
3 | `GET` | `/v1/browser` | 查看浏览器登录态元数据、持久化记录、插件在线状态和 `fresh` / `stale` / `lost` 状态 |
4 | `GET` | `/v1/browser/claude/current` | 查看当前 Claude 代理回读结果;这是 legacy Claude 辅助读接口,不是浏览器桥接主模型 |
5+| `GET` | `/v1/renewal/conversations?platform=chatgpt&status=paused` | 查看续命对话自动化状态,可按 `platform` / `status` 过滤 |
6+| `GET` | `/v1/renewal/conversations/:local_conversation_id` | 查看单个本地续命对话、当前自动化状态和 active link |
7+| `GET` | `/v1/renewal/links?platform=chatgpt&remote_conversation_id=conv_xxx` | 查看平台对话到本地对话、页面路由和代理目标的关联 |
8+| `GET` | `/v1/renewal/jobs?status=pending&local_conversation_id=lc_xxx` | 查看续命任务列表,可按 `status` / `local_conversation_id` / `message_id` 过滤 |
9+| `GET` | `/v1/renewal/jobs/:job_id` | 查看单个续命任务的 payload、目标快照、重试和最终状态 |
10 | `GET` | `/v1/controllers?limit=20` | 查看当前 controller 摘要 |
11 | `GET` | `/v1/tasks?status=queued&limit=20` | 查看任务列表,可按 `status` 过滤 |
12 | `GET` | `/v1/tasks/:task_id` | 查看单个任务详情 |
13@@ -81,6 +86,10 @@
14 - `GET /v1/browser` 会返回当前浏览器风控默认值和运行中 target/platform 状态摘要,便于观察限流、退避和熔断
15 - `GET /v1/browser` 的 `policy.defaults.stale_lease` 会暴露后台清扫阈值;`policy.targets[]` 会补 `last_activity_*`、`stale_sweep_count`、`last_stale_sweep_*`,便于判断 slot 是否曾被后台自愈回收
16 - `send` / `current` 不是 DOM 自动化,而是通过插件已有的页面内 HTTP 代理完成
17+- renewal 读面当前分成三层:
18+ - `conversations` 看自动化状态和 active link
19+ - `links` 看平台对话到本地对话、页面和 tab 目标的映射
20+ - `jobs` 看续命 dispatcher 的 `pending / running / done / failed`、重试次数、错误和目标快照
21 - ChatGPT raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`
22 - 如果没有活跃 Firefox bridge client,会返回 `503`
23 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
+16,
-0
1@@ -112,6 +112,22 @@ browser/plugin 管理约定:
2 | `POST` | `/v1/system/resume` | 切到 `running` |
3 | `POST` | `/v1/system/drain` | 切到 `draining` |
4
5+### 续命自动化控制
6+
7+| 方法 | 路径 | 作用 |
8+| --- | --- | --- |
9+| `POST` | `/v1/renewal/conversations/:local_conversation_id/manual` | 将指定本地对话切到 `manual`,停止自动生成和推进续命 |
10+| `POST` | `/v1/renewal/conversations/:local_conversation_id/auto` | 将指定本地对话切到 `auto`,允许 projector / dispatcher 继续推进 |
11+| `POST` | `/v1/renewal/conversations/:local_conversation_id/paused` | 将指定本地对话切到 `paused`,保留对话和任务,但暂停自动执行 |
12+
13+续命控制约定:
14+
15+- 先通过业务读面查看当前状态:
16+ - `GET /v1/renewal/conversations/:local_conversation_id`
17+ - `GET /v1/renewal/jobs?local_conversation_id=...`
18+- `paused` 不会删除任务,只会阻止 dispatcher 继续推进待执行 job
19+- `manual` 和 `auto` / `paused` 共用同一份 `local_conversations` 后端状态,不存在插件侧单独影子开关
20+
21 ### 本机 Host Ops
22
23 | 方法 | 路径 | 作用 |
+21,
-6
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- 当前状态:`待开始`
6+- 当前状态:`已完成`
7 - 规模预估:`M`
8 - 依赖任务:`T-S055`、`T-S056`、`T-S057`、`T-S058`
9 - 建议执行者:`Codex`
10@@ -159,22 +159,37 @@
11
12 ### 开始执行
13
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-03-30 16:34:10 CST`
18 - 状态变更:`待开始` → `进行中`
19
20 ### 完成摘要
21
22-- 完成时间:
23+- 完成时间:`2026-03-30 16:57:56 CST`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+ - `apps/conductor-daemon/src/renewal/dispatcher.ts`
27+ - `apps/conductor-daemon/src/renewal/projector.ts`
28+ - `apps/conductor-daemon/src/local-api.ts`
29+ - `apps/conductor-daemon/src/index.ts`
30+ - `apps/conductor-daemon/src/index.test.js`
31+ - `docs/api/business-interfaces.md`
32+ - `docs/api/control-interfaces.md`
33+ - `tasks/T-S059.md`
34 - 核心实现思路:
35+ - 新增 `renewal.dispatcher` timed-job runner,扫描 `next_attempt_at <= now` 的 `pending` job,执行前重新读取对话自动化状态和 active link,只通过 `browser.proxy_delivery` 发送续命消息
36+ - 为 dispatcher 补齐单次执行超时、失败重试、指数退避、最终失败和 `paused/manual` 对话延后重查逻辑,并把完整阶段写入 timed-jobs JSONL 外部日志
37+ - 在 `/v1/renewal/` 下补充 `jobs` 列表/详情读接口,保留现有 `conversations` / `links` 作为最小排障读面,同时把 renewal 读写接口写入 API 文档
38+ - 顺手收紧 projector 的 route 可用性判断,只为具备真实 `browser.proxy_delivery` tab 目标的对话生成续命任务
39 - 跑了哪些测试:
40+ - `cd /Users/george/code/baa-conductor-renewal-dispatcher-ops && pnpm install`
41+ - `cd /Users/george/code/baa-conductor-renewal-dispatcher-ops && pnpm -C apps/conductor-daemon test`
42+ - `cd /Users/george/code/baa-conductor-renewal-dispatcher-ops && pnpm build`
43
44 ### 执行过程中遇到的问题
45
46--
47+- 新 worktree 初始没有安装依赖,第一次 `pnpm -C apps/conductor-daemon test` 失败于 `pnpm exec tsc` 找不到;补跑一次 `pnpm install` 后恢复正常构建和测试流程
48
49 ### 剩余风险
50
51--
52+- 当前 dispatcher 只处理 `pending -> running -> done/failed` 的首版闭环,还没有做“进程意外退出后回收陈旧 running 任务”的恢复逻辑;如果后续需要跨重启补偿,需要再补 stale-running reconcile