baa-conductor

git clone 

commit
b8d69c8
parent
7074d4c
author
codex@macbookpro
date
2026-04-01 10:19:37 +0800 CST
feat: add renewal dispatcher jitter
3 files changed,  +450, -18
M apps/conductor-daemon/src/index.test.js
+249, -0
  1@@ -3640,6 +3640,165 @@ test("renewal dispatcher sends due pending jobs through browser.proxy_delivery a
  2   }
  3 });
  4 
  5+test("renewal dispatcher adds inter-job jitter before consecutive dispatches and logs it", async () => {
  6+  const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-jitter-"));
  7+  const stateDir = join(rootDir, "state");
  8+  const artifactStore = new ArtifactStore({
  9+    artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
 10+    databasePath: join(stateDir, ARTIFACT_DB_FILENAME)
 11+  });
 12+  const dispatchTimes = [];
 13+  const randomValues = [0, 1];
 14+  let randomIndex = 0;
 15+  const runner = createRenewalDispatcherRunner({
 16+    browserBridge: {
 17+      proxyDelivery(input) {
 18+        const dispatchedAt = Date.now();
 19+        dispatchTimes.push(dispatchedAt);
 20+
 21+        return {
 22+          clientId: input.clientId || "firefox-chatgpt",
 23+          connectionId: "conn-firefox-chatgpt",
 24+          dispatchedAt,
 25+          requestId: `proxy-dispatch-jitter-${dispatchTimes.length}`,
 26+          result: Promise.resolve({
 27+            accepted: true,
 28+            action: "proxy_delivery",
 29+            completed: true,
 30+            failed: false,
 31+            reason: null,
 32+            received_at: dispatchedAt + 5,
 33+            request_id: `proxy-dispatch-jitter-${dispatchTimes.length}`,
 34+            result: {
 35+              actual_count: 1,
 36+              desired_count: 1,
 37+              drift_count: 0,
 38+              failed_count: 0,
 39+              ok_count: 1,
 40+              platform_count: 1,
 41+              restored_count: 0,
 42+              skipped_reasons: []
 43+            },
 44+            results: [],
 45+            shell_runtime: [],
 46+            target: {
 47+              client_id: input.clientId || "firefox-chatgpt",
 48+              connection_id: "conn-firefox-chatgpt",
 49+              platform: input.platform,
 50+              requested_client_id: input.clientId || "firefox-chatgpt",
 51+              requested_platform: input.platform
 52+            },
 53+            type: "browser.proxy_delivery"
 54+          }),
 55+          type: "browser.proxy_delivery"
 56+        };
 57+      }
 58+    },
 59+    interJobJitterMaxMs: 45,
 60+    interJobJitterMinMs: 25,
 61+    random: () => randomValues[randomIndex++] ?? 0.5
 62+  });
 63+  const baseNowMs = Date.now() - 1_000;
 64+
 65+  try {
 66+    for (let index = 1; index <= 3; index += 1) {
 67+      const conversationId = `conv_dispatch_jitter_${index}`;
 68+      const localConversationId = `lc_dispatch_jitter_${index}`;
 69+      const messageId = `msg_dispatch_jitter_${index}`;
 70+      const jobId = `job_dispatch_jitter_${index}`;
 71+
 72+      await artifactStore.insertMessage({
 73+        conversationId,
 74+        id: messageId,
 75+        observedAt: baseNowMs - (index * 1_000),
 76+        platform: "chatgpt",
 77+        rawText: `renewal dispatcher jitter message ${index}`,
 78+        role: "assistant"
 79+      });
 80+      await artifactStore.upsertLocalConversation({
 81+        automationStatus: "auto",
 82+        localConversationId,
 83+        platform: "chatgpt",
 84+        updatedAt: baseNowMs - 500
 85+      });
 86+      await artifactStore.upsertConversationLink({
 87+        clientId: "firefox-chatgpt",
 88+        linkId: `link_dispatch_jitter_${index}`,
 89+        localConversationId,
 90+        observedAt: baseNowMs - 500,
 91+        pageTitle: `Dispatch Jitter ${index}`,
 92+        pageUrl: `https://chatgpt.com/c/${conversationId}`,
 93+        platform: "chatgpt",
 94+        remoteConversationId: conversationId,
 95+        routeParams: {
 96+          conversationId
 97+        },
 98+        routePath: `/c/${conversationId}`,
 99+        routePattern: "/c/:conversationId",
100+        targetId: `tab:${30 + index}`,
101+        targetKind: "browser.proxy_delivery",
102+        targetPayload: {
103+          clientId: "firefox-chatgpt",
104+          conversationId,
105+          pageUrl: `https://chatgpt.com/c/${conversationId}`,
106+          tabId: 30 + index
107+        }
108+      });
109+      await artifactStore.insertRenewalJob({
110+        jobId,
111+        localConversationId,
112+        messageId,
113+        nextAttemptAt: baseNowMs,
114+        payload: `[renewal] jitter ${index}`,
115+        payloadKind: "text"
116+      });
117+    }
118+
119+    const { context, entries } = createTimedJobRunnerContext({
120+      artifactStore,
121+      config: {
122+        intervalMs: 5_000,
123+        maxMessagesPerTick: 10,
124+        maxTasksPerTick: 10,
125+        settleDelayMs: 0
126+      }
127+    });
128+    const result = await runner.run(context);
129+    const jitterEntries = entries.filter((entry) => entry.stage === "job_dispatch_jitter");
130+    const startedEntries = entries.filter((entry) => entry.stage === "job_attempt_started");
131+
132+    assert.equal(result.result, "ok");
133+    assert.equal(result.details.successful_jobs, 3);
134+    assert.equal(dispatchTimes.length, 3);
135+    assert.ok(
136+      dispatchTimes[1] - dispatchTimes[0] >= 20,
137+      `expected second dispatch gap >= 20ms, got ${dispatchTimes[1] - dispatchTimes[0]}ms`
138+    );
139+    assert.ok(
140+      dispatchTimes[2] - dispatchTimes[1] >= 35,
141+      `expected third dispatch gap >= 35ms, got ${dispatchTimes[2] - dispatchTimes[1]}ms`
142+    );
143+    assert.deepEqual(
144+      jitterEntries.map((entry) => entry.details?.jitter_ms),
145+      [25, 45]
146+    );
147+    assert.deepEqual(
148+      jitterEntries.map((entry) => entry.details?.dispatch_sequence_in_tick),
149+      [2, 3]
150+    );
151+    assert.deepEqual(
152+      startedEntries.map((entry) => entry.details?.inter_job_jitter_ms),
153+      [0, 25, 45]
154+    );
155+  } finally {
156+    artifactStore.close();
157+    rmSync(rootDir, {
158+      force: true,
159+      recursive: true
160+    });
161+  }
162+});
163+
164 test("renewal dispatcher success writes cooldownUntil and blocks projector from projecting follow-up messages during cooldown", async () => {
165   const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-cooldown-chain-"));
166   const stateDir = join(rootDir, "state");
167@@ -3821,6 +3980,96 @@ test("renewal dispatcher success writes cooldownUntil and blocks projector from
168   }
169 });
170 
171+test("renewal dispatcher adds retry jitter so same-attempt failures do not reschedule to the same timestamp", async () => {
172+  const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-retry-jitter-"));
173+  const stateDir = join(rootDir, "state");
174+  const artifactStore = new ArtifactStore({
175+    artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
176+    databasePath: join(stateDir, ARTIFACT_DB_FILENAME)
177+  });
178+  const nowMs = Date.UTC(2026, 2, 30, 13, 30, 0);
179+  const randomValues = [0, 1];
180+  let randomIndex = 0;
181+  const runner = createRenewalDispatcherRunner({
182+    browserBridge: {
183+      proxyDelivery() {
184+        assert.fail("jobs missing active links should not call browser.proxy_delivery");
185+      }
186+    },
187+    now: () => nowMs,
188+    random: () => randomValues[randomIndex++] ?? 0.5,
189+    retryBaseDelayMs: 1_000,
190+    retryJitterFactor: 0.3,
191+    retryMaxDelayMs: 10_000
192+  });
193+
194+  try {
195+    for (let index = 1; index <= 2; index += 1) {
196+      const conversationId = `conv_dispatch_retry_jitter_${index}`;
197+      const localConversationId = `lc_dispatch_retry_jitter_${index}`;
198+      const messageId = `msg_dispatch_retry_jitter_${index}`;
199+
200+      await artifactStore.insertMessage({
201+        conversationId,
202+        id: messageId,
203+        observedAt: nowMs - (index * 1_000),
204+        platform: "claude",
205+        rawText: `renewal dispatcher retry jitter message ${index}`,
206+        role: "assistant"
207+      });
208+      await artifactStore.upsertLocalConversation({
209+        automationStatus: "auto",
210+        localConversationId,
211+        platform: "claude",
212+        updatedAt: nowMs - 500
213+      });
214+      await artifactStore.insertRenewalJob({
215+        jobId: `job_dispatch_retry_jitter_${index}`,
216+        localConversationId,
217+        maxAttempts: 3,
218+        messageId,
219+        nextAttemptAt: nowMs,
220+        payload: `[renewal] retry jitter ${index}`,
221+        payloadKind: "text"
222+      });
223+    }
224+
225+    const { context, entries } = createTimedJobRunnerContext({
226+      artifactStore
227+    });
228+    const result = await runner.run(context);
229+    const firstJob = await artifactStore.getRenewalJob("job_dispatch_retry_jitter_1");
230+    const secondJob = await artifactStore.getRenewalJob("job_dispatch_retry_jitter_2");
231+    const retryEntries = entries.filter((entry) => entry.stage === "job_retry_scheduled");
232+
233+    assert.equal(result.result, "ok");
234+    assert.equal(result.details.retried_jobs, 2);
235+    assert.equal(firstJob.status, "pending");
236+    assert.equal(secondJob.status, "pending");
237+    assert.equal(firstJob.nextAttemptAt, nowMs + 700);
238+    assert.equal(secondJob.nextAttemptAt, nowMs + 1_300);
239+    assert.notEqual(firstJob.nextAttemptAt, secondJob.nextAttemptAt);
240+    assert.deepEqual(
241+      retryEntries.map((entry) => entry.details?.retry_base_delay_ms),
242+      [1_000, 1_000]
243+    );
244+    assert.deepEqual(
245+      retryEntries.map((entry) => entry.details?.retry_jitter_ms),
246+      [-300, 300]
247+    );
248+    assert.deepEqual(
249+      retryEntries.map((entry) => entry.details?.retry_delay_ms),
250+      [700, 1_300]
251+    );
252+  } finally {
253+    artifactStore.close();
254+    rmSync(rootDir, {
255+      force: true,
256+      recursive: true
257+    });
258+  }
259+});
260+
261 test("renewal dispatcher defers paused jobs and retries transient proxy failures until failed", async () => {
262   const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-retry-"));
263   const stateDir = join(rootDir, "state");
M apps/conductor-daemon/src/renewal/dispatcher.ts
+188, -17
  1@@ -23,7 +23,10 @@ import {
  2 } from "./projector.js";
  3 
  4 const DEFAULT_RECHECK_DELAY_MS = 10_000;
  5+const DEFAULT_INTER_JOB_JITTER_MIN_MS = 500;
  6+const DEFAULT_INTER_JOB_JITTER_MAX_MS = 3_000;
  7 const DEFAULT_RETRY_BASE_DELAY_MS = 30_000;
  8+const DEFAULT_RETRY_JITTER_FACTOR = 0.3;
  9 const DEFAULT_RETRY_MAX_DELAY_MS = 5 * 60_000;
 10 const DEFAULT_SUCCESS_COOLDOWN_MS = 60_000;
 11 const PROXY_DELIVERY_TARGET_KIND = "browser.proxy_delivery";
 12@@ -87,13 +90,30 @@ interface RenewalExecutionFailure {
 13 interface RenewalDispatcherRunnerOptions {
 14   browserBridge: BrowserBridgeController | null;
 15   executionTimeoutMs?: number;
 16+  interJobJitterMaxMs?: number;
 17+  interJobJitterMinMs?: number;
 18   now?: () => number;
 19+  random?: () => number;
 20   recheckDelayMs?: number;
 21   retryBaseDelayMs?: number;
 22+  retryJitterFactor?: number;
 23   retryMaxDelayMs?: number;
 24   successCooldownMs?: number;
 25 }
 26 
 27+interface RenewalDispatcherJitterSettings {
 28+  interJobJitterMaxMs: number;
 29+  interJobJitterMinMs: number;
 30+  random: () => number;
 31+  retryJitterFactor: number;
 32+}
 33+
 34+interface RenewalRetryDelaySchedule {
 35+  baseDelayMs: number;
 36+  delayMs: number;
 37+  jitterMs: number;
 38+}
 39+
 40 export function createRenewalDispatcherRunner(
 41   options: RenewalDispatcherRunnerOptions
 42 ): TimedJobRunner {
 43@@ -128,6 +148,7 @@ export async function runRenewalDispatcher(
 44   }
 45 
 46   const now = options.now ?? (() => Date.now());
 47+  const jitterSettings = resolveDispatcherJitterSettings(options);
 48   const nowMs = now();
 49   const dueJobs = await artifactStore.listRenewalJobs({
 50     limit: context.maxTasksPerTick,
 51@@ -160,11 +181,13 @@ export async function runRenewalDispatcher(
 52   }
 53 
 54   let failedJobs = 0;
 55+  let dispatchedJobs = 0;
 56   let retriedJobs = 0;
 57   let skippedJobs = 0;
 58   let successfulJobs = 0;
 59 
 60   for (const job of dueJobs) {
 61+    const jobNowMs = now();
 62     const dispatchContext = await resolveDispatchContext(artifactStore, job);
 63 
 64     if (dispatchContext.conversation == null) {
 65@@ -172,7 +195,8 @@ export async function runRenewalDispatcher(
 66       await markJobFailed(artifactStore, job, {
 67         attemptCount: job.attemptCount + 1,
 68         lastError: "missing_local_conversation",
 69-        logPath: resolveJobLogPath(job.logPath, context.logDir, nowMs),
 70+        logPath: resolveJobLogPath(job.logPath, context.logDir, jobNowMs),
 71+        now: jobNowMs,
 72         targetSnapshot: dispatchContext.targetSnapshot
 73       });
 74       context.log({
 75@@ -190,16 +214,16 @@ export async function runRenewalDispatcher(
 76 
 77     if (dispatchContext.conversation.automationStatus !== "auto") {
 78       skippedJobs += 1;
 79-      const nextAttemptAt = nowMs + resolvePositiveInteger(
 80+      const nextAttemptAt = jobNowMs + resolvePositiveInteger(
 81         options.recheckDelayMs,
 82         Math.max(DEFAULT_RECHECK_DELAY_MS, context.config.intervalMs)
 83       );
 84       await artifactStore.updateRenewalJob({
 85         jobId: job.jobId,
 86-        logPath: resolveJobLogPath(job.logPath, context.logDir, nowMs),
 87+        logPath: resolveJobLogPath(job.logPath, context.logDir, jobNowMs),
 88         nextAttemptAt,
 89         targetSnapshot: dispatchContext.targetSnapshot,
 90-        updatedAt: nowMs
 91+        updatedAt: jobNowMs
 92       });
 93       context.log({
 94         stage: "job_deferred",
 95@@ -224,9 +248,11 @@ export async function runRenewalDispatcher(
 96         attemptCount: attempts,
 97         errorMessage: dispatchContext.link == null ? "missing_active_link" : "route_unavailable",
 98         logDir: context.logDir,
 99-        now: nowMs,
100+        now: jobNowMs,
101         retryBaseDelayMs: options.retryBaseDelayMs,
102+        retryJitterFactor: jitterSettings.retryJitterFactor,
103         retryMaxDelayMs: options.retryMaxDelayMs,
104+        random: jitterSettings.random,
105         targetSnapshot: dispatchContext.targetSnapshot
106       });
107 
108@@ -250,7 +276,10 @@ export async function runRenewalDispatcher(
109             attempt_count: attempts,
110             job_id: job.jobId,
111             message_id: job.messageId,
112-            next_attempt_at: failureResult.nextAttemptAt
113+            next_attempt_at: failureResult.nextAttemptAt,
114+            retry_base_delay_ms: failureResult.baseDelayMs,
115+            retry_delay_ms: failureResult.delayMs,
116+            retry_jitter_ms: failureResult.jitterMs
117           }
118         });
119       }
120@@ -265,7 +294,8 @@ export async function runRenewalDispatcher(
121       await markJobFailed(artifactStore, job, {
122         attemptCount: job.attemptCount + 1,
123         lastError: errorMessage,
124-        logPath: resolveJobLogPath(job.logPath, context.logDir, nowMs),
125+        logPath: resolveJobLogPath(job.logPath, context.logDir, jobNowMs),
126+        now: jobNowMs,
127         targetSnapshot: dispatchContext.targetSnapshot
128       });
129       context.log({
130@@ -280,27 +310,51 @@ export async function runRenewalDispatcher(
131       continue;
132     }
133 
134+    const interJobJitterMs = dispatchedJobs > 0
135+      ? sampleInterJobJitterMs(jitterSettings)
136+      : 0;
137+
138+    if (interJobJitterMs > 0) {
139+      context.log({
140+        stage: "job_dispatch_jitter",
141+        result: "inter_job_jitter_applied",
142+        details: {
143+          dispatch_sequence_in_tick: dispatchedJobs + 1,
144+          job_id: job.jobId,
145+          jitter_ms: interJobJitterMs,
146+          local_conversation_id: job.localConversationId,
147+          message_id: job.messageId
148+        }
149+      });
150+      await sleep(interJobJitterMs);
151+    }
152+
153+    const attemptStartedAt = now();
154     const runningJob = await artifactStore.updateRenewalJob({
155       finishedAt: null,
156       jobId: job.jobId,
157-      lastAttemptAt: nowMs,
158+      lastAttemptAt: attemptStartedAt,
159       lastError: null,
160-      logPath: resolveJobLogPath(job.logPath, context.logDir, nowMs),
161+      logPath: resolveJobLogPath(job.logPath, context.logDir, attemptStartedAt),
162       nextAttemptAt: null,
163-      startedAt: nowMs,
164+      startedAt: attemptStartedAt,
165       status: "running",
166       targetSnapshot: dispatchContext.targetSnapshot,
167-      updatedAt: nowMs
168+      updatedAt: attemptStartedAt
169     });
170+    dispatchedJobs += 1;
171     context.log({
172       stage: "job_attempt_started",
173       result: "attempt_started",
174       details: {
175         attempt_count: job.attemptCount + 1,
176         client_id: dispatchContext.target.clientId,
177+        dispatch_sequence_in_tick: dispatchedJobs,
178+        inter_job_jitter_ms: interJobJitterMs,
179         job_id: job.jobId,
180         local_conversation_id: job.localConversationId,
181         message_id: job.messageId,
182+        started_at: attemptStartedAt,
183         tab_id: dispatchContext.target.tabId
184       }
185     });
186@@ -326,11 +380,11 @@ export async function runRenewalDispatcher(
187         attemptCount,
188         finishedAt,
189         jobId: job.jobId,
190-        lastAttemptAt: nowMs,
191+        lastAttemptAt: attemptStartedAt,
192         lastError: null,
193-        logPath: resolveJobLogPath(job.logPath, context.logDir, nowMs),
194+        logPath: resolveJobLogPath(job.logPath, context.logDir, attemptStartedAt),
195         nextAttemptAt: null,
196-        startedAt: runningJob.startedAt ?? nowMs,
197+        startedAt: runningJob.startedAt ?? attemptStartedAt,
198         status: "done",
199         targetSnapshot: dispatchContext.targetSnapshot,
200         updatedAt: finishedAt
201@@ -358,7 +412,9 @@ export async function runRenewalDispatcher(
202         logDir: context.logDir,
203         now: now(),
204         retryBaseDelayMs: options.retryBaseDelayMs,
205+        retryJitterFactor: jitterSettings.retryJitterFactor,
206         retryMaxDelayMs: options.retryMaxDelayMs,
207+        random: jitterSettings.random,
208         targetSnapshot: dispatchContext.targetSnapshot
209       });
210 
211@@ -388,6 +444,9 @@ export async function runRenewalDispatcher(
212             job_id: job.jobId,
213             message_id: job.messageId,
214             next_attempt_at: failureResult.nextAttemptAt,
215+            retry_base_delay_ms: failureResult.baseDelayMs,
216+            retry_delay_ms: failureResult.delayMs,
217+            retry_jitter_ms: failureResult.jitterMs,
218             timeout_ms: failure.timeoutMs
219           }
220         });
221@@ -500,7 +559,9 @@ async function applyFailureOutcome(
222     logDir: string | null;
223     now: number;
224     retryBaseDelayMs?: number;
225+    retryJitterFactor?: number;
226     retryMaxDelayMs?: number;
227+    random?: () => number;
228     targetSnapshot: RenewalProjectorTargetSnapshot | null;
229   }
230 ): Promise<
231@@ -509,6 +570,9 @@ async function applyFailureOutcome(
232       status: "failed";
233     }
234   | {
235+      baseDelayMs: number;
236+      delayMs: number;
237+      jitterMs: number;
238       nextAttemptAt: number;
239       result: string;
240       status: "pending";
241@@ -531,10 +595,13 @@ async function applyFailureOutcome(
242     };
243   }
244 
245-  const nextAttemptAt = input.now + computeRetryDelayMs(input.attemptCount, {
246+  const retryDelay = computeRetryDelayMs(input.attemptCount, {
247+    random: input.random,
248     retryBaseDelayMs: input.retryBaseDelayMs,
249+    retryJitterFactor: input.retryJitterFactor,
250     retryMaxDelayMs: input.retryMaxDelayMs
251   });
252+  const nextAttemptAt = input.now + retryDelay.delayMs;
253   await artifactStore.updateRenewalJob({
254     attemptCount: input.attemptCount,
255     finishedAt: null,
256@@ -549,6 +616,9 @@ async function applyFailureOutcome(
257     updatedAt: input.now
258   });
259   return {
260+    baseDelayMs: retryDelay.baseDelayMs,
261+    delayMs: retryDelay.delayMs,
262+    jitterMs: retryDelay.jitterMs,
263     nextAttemptAt,
264     result: resolvedError,
265     status: "pending"
266@@ -603,14 +673,95 @@ function buildRenewalPlanId(messageId: string): string {
267 function computeRetryDelayMs(
268   attemptCount: number,
269   options: {
270+    random?: () => number;
271     retryBaseDelayMs?: number;
272+    retryJitterFactor?: number;
273     retryMaxDelayMs?: number;
274   }
275-): number {
276+): RenewalRetryDelaySchedule {
277   const baseDelayMs = resolvePositiveInteger(options.retryBaseDelayMs, DEFAULT_RETRY_BASE_DELAY_MS);
278   const maxDelayMs = resolvePositiveInteger(options.retryMaxDelayMs, DEFAULT_RETRY_MAX_DELAY_MS);
279+  const retryJitterFactor = resolveNonNegativeNumber(
280+    options.retryJitterFactor,
281+    DEFAULT_RETRY_JITTER_FACTOR
282+  );
283   const exponent = Math.max(0, attemptCount - 1);
284-  return Math.min(maxDelayMs, baseDelayMs * (2 ** exponent));
285+  const cappedBaseDelayMs = Math.min(maxDelayMs, baseDelayMs * (2 ** exponent));
286+
287+  if (retryJitterFactor === 0 || cappedBaseDelayMs <= 0) {
288+    return {
289+      baseDelayMs: cappedBaseDelayMs,
290+      delayMs: cappedBaseDelayMs,
291+      jitterMs: 0
292+    };
293+  }
294+
295+  const centeredRandom = (sampleRandomUnit(options.random) * 2) - 1;
296+  const sampledJitterMs = Math.round(cappedBaseDelayMs * retryJitterFactor * centeredRandom);
297+  const delayMs = Math.max(1, Math.min(maxDelayMs, cappedBaseDelayMs + sampledJitterMs));
298+
299+  return {
300+    baseDelayMs: cappedBaseDelayMs,
301+    delayMs,
302+    jitterMs: delayMs - cappedBaseDelayMs
303+  };
304+}
305+
306+function sampleInterJobJitterMs(settings: RenewalDispatcherJitterSettings): number {
307+  return sampleUniformJitterMs(
308+    settings.interJobJitterMinMs,
309+    settings.interJobJitterMaxMs,
310+    settings.random
311+  );
312+}
313+
314+function sampleUniformJitterMs(
315+  minMs: number,
316+  maxMs: number,
317+  random?: () => number
318+): number {
319+  const normalizedMinMs = resolveNonNegativeInteger(Math.min(minMs, maxMs), 0);
320+  const normalizedMaxMs = resolveNonNegativeInteger(Math.max(minMs, maxMs), 0);
321+
322+  if (normalizedMaxMs <= normalizedMinMs) {
323+    return normalizedMinMs;
324+  }
325+
326+  const offsetMs = normalizedMaxMs - normalizedMinMs;
327+  return normalizedMinMs + Math.round(offsetMs * sampleRandomUnit(random));
328+}
329+
330+function resolveDispatcherJitterSettings(
331+  options: RenewalDispatcherRunnerOptions
332+): RenewalDispatcherJitterSettings {
333+  const minJitterMs = resolveNonNegativeInteger(
334+    options.interJobJitterMinMs,
335+    DEFAULT_INTER_JOB_JITTER_MIN_MS
336+  );
337+  const maxJitterMs = resolveNonNegativeInteger(
338+    options.interJobJitterMaxMs,
339+    DEFAULT_INTER_JOB_JITTER_MAX_MS
340+  );
341+
342+  return {
343+    interJobJitterMaxMs: Math.max(minJitterMs, maxJitterMs),
344+    interJobJitterMinMs: Math.min(minJitterMs, maxJitterMs),
345+    random: options.random ?? Math.random,
346+    retryJitterFactor: resolveNonNegativeNumber(
347+      options.retryJitterFactor,
348+      DEFAULT_RETRY_JITTER_FACTOR
349+    )
350+  };
351+}
352+
353+function sampleRandomUnit(random?: () => number): number {
354+  const sampled = random == null ? Math.random() : random();
355+
356+  if (!Number.isFinite(sampled)) {
357+    return 0.5;
358+  }
359+
360+  return Math.min(1, Math.max(0, sampled));
361 }
362 
363 function normalizeDispatchPayload(
364@@ -814,6 +965,14 @@ function resolvePositiveInteger(value: number | undefined, fallback: number): nu
365   return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
366 }
367 
368+function resolveNonNegativeInteger(value: number | undefined, fallback: number): number {
369+  return Number.isInteger(value) && Number(value) >= 0 ? Number(value) : fallback;
370+}
371+
372+function resolveNonNegativeNumber(value: number | undefined, fallback: number): number {
373+  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
374+}
375+
376 function resolveSuccessCooldownMs(value: number | undefined, intervalMs: number): number {
377   if (Number.isInteger(value) && Number(value) > 0) {
378     return Number(value);
379@@ -822,6 +981,18 @@ function resolveSuccessCooldownMs(value: number | undefined, intervalMs: number)
380   return Math.max(DEFAULT_SUCCESS_COOLDOWN_MS, intervalMs + 1);
381 }
382 
383+async function sleep(ms: number): Promise<void> {
384+  const delayMs = resolveNonNegativeInteger(Math.round(ms), 0);
385+
386+  if (delayMs === 0) {
387+    return;
388+  }
389+
390+  await new Promise<void>((resolve) => {
391+    setTimeout(resolve, delayMs);
392+  });
393+}
394+
395 function sanitizePathSegment(value: string): string {
396   const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-");
397   const collapsed = normalized.replace(/-+/gu, "-").replace(/^-|-$/gu, "");
M plans/OPT-007-DISPATCHER-JITTER.md
+13, -1
 1@@ -2,7 +2,7 @@
 2 
 3 日期:`2026-03-30`
 4 优先级:`P2`
 5-状态:`proposed`
 6+状态:`completed`
 7 
 8 ## 背景
 9 
10@@ -54,3 +54,15 @@
11 - 两个相同 attemptCount 的 failed job 重试时间不完全相同
12 - 现有 dispatcher 测试全部通过
13 - `random` option 可注入,测试可使用固定种子验证抖动范围
14+
15+## 完成摘要
16+
17+- 完成时间:`2026-04-01`
18+- 实现位置:`apps/conductor-daemon/src/renewal/dispatcher.ts`
19+- 实际落地:
20+  - 新增集中式 jitter 配置:`interJobJitterMinMs`、`interJobJitterMaxMs`、`retryJitterFactor`、`random`
21+  - 同一 tick 内从第 2 个实际 dispatch 起,在发送前统一走 inter-job jitter
22+  - retry 继续保留原有指数退避和 max-delay 语义,只在其上叠加可控 jitter 偏移
23+  - dispatcher 日志新增 inter-job jitter 记录,并在 `job_retry_scheduled` 中输出 `retry_base_delay_ms`、`retry_delay_ms`、`retry_jitter_ms`
24+- 验证:
25+  - `pnpm -C apps/conductor-daemon test`