- 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
+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");
+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, "");
+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`