baa-conductor

git clone 

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
M apps/conductor-daemon/src/index.test.js
+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-"));
M apps/conductor-daemon/src/index.ts
+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({
M apps/conductor-daemon/src/local-api.ts
+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":
A apps/conductor-daemon/src/renewal/dispatcher.ts
+807, -0
  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+}
M apps/conductor-daemon/src/renewal/projector.ts
+27, -6
 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;
M docs/api/business-interfaces.md
+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`
M docs/api/control-interfaces.md
+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 | 方法 | 路径 | 作用 |
M tasks/T-S059.md
+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