- commit
- 5580526
- parent
- 3e66aa3
- author
- codex@macbookpro
- date
- 2026-03-30 17:56:11 +0800 CST
fix: persist renewal cooldown after successful dispatch
2 files changed,
+201,
-0
+184,
-0
1@@ -3307,6 +3307,7 @@ test("renewal dispatcher sends due pending jobs through browser.proxy_delivery a
2 });
3 const result = await runner.run(context);
4 const job = await artifactStore.getRenewalJob("job_dispatch_success");
5+ const conversation = await artifactStore.getLocalConversation("lc_dispatch_success");
6
7 assert.equal(result.result, "ok");
8 assert.equal(browserCalls.length, 1);
9@@ -3318,6 +3319,8 @@ test("renewal dispatcher sends due pending jobs through browser.proxy_delivery a
10 assert.equal(job.nextAttemptAt, null);
11 assert.equal(typeof job.finishedAt, "number");
12 assert.equal(JSON.parse(job.targetSnapshot).target.kind, "browser.proxy_delivery");
13+ assert.equal(conversation.cooldownUntil, nowMs + 60_000);
14+ assert.equal(conversation.updatedAt, nowMs);
15 assert.ok(entries.find((entry) => entry.stage === "job_attempt_started"));
16 assert.ok(entries.find((entry) => entry.stage === "job_completed" && entry.result === "attempt_succeeded"));
17 } finally {
18@@ -3333,6 +3336,187 @@ test("renewal dispatcher sends due pending jobs through browser.proxy_delivery a
19 }
20 });
21
22+test("renewal dispatcher success writes cooldownUntil and blocks projector from projecting follow-up messages during cooldown", async () => {
23+ const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-cooldown-chain-"));
24+ const stateDir = join(rootDir, "state");
25+ const localApiFixture = await createLocalApiFixture({
26+ databasePath: join(rootDir, "control-plane.sqlite")
27+ });
28+ const artifactStore = new ArtifactStore({
29+ artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
30+ databasePath: join(stateDir, ARTIFACT_DB_FILENAME)
31+ });
32+ let nowMs = Date.UTC(2026, 2, 30, 14, 0, 0);
33+ const browserCalls = [];
34+ const projector = createRenewalProjectorRunner({
35+ now: () => nowMs,
36+ repository: localApiFixture.repository
37+ });
38+ const dispatcher = createRenewalDispatcherRunner({
39+ browserBridge: {
40+ proxyDelivery(input) {
41+ browserCalls.push(input);
42+ return {
43+ clientId: input.clientId || "firefox-claude",
44+ connectionId: "conn-firefox-claude",
45+ dispatchedAt: nowMs,
46+ requestId: "proxy-chain-1",
47+ result: Promise.resolve({
48+ accepted: true,
49+ action: "proxy_delivery",
50+ completed: true,
51+ failed: false,
52+ reason: null,
53+ received_at: nowMs + 50,
54+ request_id: "proxy-chain-1",
55+ result: {
56+ actual_count: 1,
57+ desired_count: 1,
58+ drift_count: 0,
59+ failed_count: 0,
60+ ok_count: 1,
61+ platform_count: 1,
62+ restored_count: 0,
63+ skipped_reasons: []
64+ },
65+ results: [],
66+ shell_runtime: [],
67+ target: {
68+ client_id: input.clientId || "firefox-claude",
69+ connection_id: "conn-firefox-claude",
70+ platform: input.platform,
71+ requested_client_id: input.clientId || "firefox-claude",
72+ requested_platform: input.platform
73+ },
74+ type: "browser.proxy_delivery"
75+ }),
76+ type: "browser.proxy_delivery"
77+ };
78+ }
79+ },
80+ now: () => nowMs,
81+ successCooldownMs: 60_000
82+ });
83+
84+ try {
85+ await artifactStore.upsertLocalConversation({
86+ automationStatus: "auto",
87+ localConversationId: "lc_dispatch_chain",
88+ platform: "claude",
89+ updatedAt: nowMs - 60_000
90+ });
91+ await artifactStore.upsertConversationLink({
92+ clientId: "firefox-claude",
93+ linkId: "link_dispatch_chain",
94+ localConversationId: "lc_dispatch_chain",
95+ observedAt: nowMs - 60_000,
96+ pageTitle: "Dispatch Cooldown Chain",
97+ pageUrl: "https://claude.ai/chat/conv_dispatch_chain",
98+ platform: "claude",
99+ remoteConversationId: "conv_dispatch_chain",
100+ routeParams: {
101+ conversationId: "conv_dispatch_chain"
102+ },
103+ routePath: "/chat/conv_dispatch_chain",
104+ routePattern: "/chat/:conversationId",
105+ targetId: "tab:21",
106+ targetKind: "browser.proxy_delivery",
107+ targetPayload: {
108+ clientId: "firefox-claude",
109+ conversationId: "conv_dispatch_chain",
110+ pageUrl: "https://claude.ai/chat/conv_dispatch_chain",
111+ tabId: 21
112+ }
113+ });
114+ await artifactStore.insertMessage({
115+ conversationId: "conv_dispatch_chain",
116+ id: "msg_dispatch_chain_seed",
117+ observedAt: nowMs - 30_000,
118+ platform: "claude",
119+ rawText: "first renewal candidate should dispatch successfully",
120+ role: "assistant"
121+ });
122+
123+ const initialProjectorTick = createTimedJobRunnerContext({
124+ artifactStore,
125+ config: {
126+ intervalMs: 10_000,
127+ maxMessagesPerTick: 10,
128+ maxTasksPerTick: 10,
129+ settleDelayMs: 0
130+ }
131+ });
132+ const projected = await projector.run(initialProjectorTick.context);
133+ const projectedJobs = await artifactStore.listRenewalJobs({});
134+
135+ assert.equal(projected.result, "ok");
136+ assert.equal(projected.details.projected_jobs, 1);
137+ assert.equal(projectedJobs.length, 1);
138+ assert.equal(projectedJobs[0].messageId, "msg_dispatch_chain_seed");
139+
140+ const initialDispatcherTick = createTimedJobRunnerContext({
141+ artifactStore,
142+ config: {
143+ intervalMs: 10_000,
144+ maxMessagesPerTick: 10,
145+ maxTasksPerTick: 10,
146+ settleDelayMs: 0
147+ }
148+ });
149+ const dispatched = await dispatcher.run(initialDispatcherTick.context);
150+ const conversationAfterSuccess = await artifactStore.getLocalConversation("lc_dispatch_chain");
151+
152+ assert.equal(dispatched.result, "ok");
153+ assert.equal(dispatched.details.successful_jobs, 1);
154+ assert.equal(browserCalls.length, 1);
155+ assert.ok(conversationAfterSuccess);
156+ assert.equal(conversationAfterSuccess.cooldownUntil, nowMs + 60_000);
157+
158+ nowMs += 10_000;
159+ await artifactStore.insertMessage({
160+ conversationId: "conv_dispatch_chain",
161+ id: "msg_dispatch_chain_followup",
162+ observedAt: nowMs - 1_000,
163+ platform: "claude",
164+ rawText: "follow-up message should be skipped by cooldown",
165+ role: "assistant"
166+ });
167+
168+ const cooldownProjectorTick = createTimedJobRunnerContext({
169+ artifactStore,
170+ config: {
171+ intervalMs: 10_000,
172+ maxMessagesPerTick: 10,
173+ maxTasksPerTick: 10,
174+ settleDelayMs: 0
175+ }
176+ });
177+ const cooldownProjected = await projector.run(cooldownProjectorTick.context);
178+ const followupJobs = await artifactStore.listRenewalJobs({
179+ messageId: "msg_dispatch_chain_followup"
180+ });
181+
182+ assert.equal(cooldownProjected.result, "ok");
183+ assert.equal(cooldownProjected.details.projected_jobs, 0);
184+ assert.equal(followupJobs.length, 0);
185+ assert.ok(
186+ cooldownProjectorTick.entries.find(
187+ (entry) =>
188+ entry.stage === "message_skipped"
189+ && entry.result === "cooldown_active"
190+ && entry.details?.message_id === "msg_dispatch_chain_followup"
191+ )
192+ );
193+ } finally {
194+ artifactStore.close();
195+ localApiFixture.controlPlane.close();
196+ rmSync(rootDir, {
197+ force: true,
198+ recursive: true
199+ });
200+ }
201+});
202+
203 test("renewal dispatcher defers paused jobs and retries transient proxy failures until failed", async () => {
204 const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-retry-"));
205 const stateDir = join(rootDir, "state");
1@@ -25,6 +25,7 @@ const DEFAULT_EXECUTION_TIMEOUT_MS = 20_000;
2 const DEFAULT_RECHECK_DELAY_MS = 10_000;
3 const DEFAULT_RETRY_BASE_DELAY_MS = 30_000;
4 const DEFAULT_RETRY_MAX_DELAY_MS = 5 * 60_000;
5+const DEFAULT_SUCCESS_COOLDOWN_MS = 60_000;
6 const PROXY_DELIVERY_TARGET_KIND = "browser.proxy_delivery";
7 const RUNNER_NAME = "renewal.dispatcher";
8 const TAB_TARGET_ID_PATTERN = /^tab:(\d+)$/u;
9@@ -83,6 +84,7 @@ interface RenewalDispatcherRunnerOptions {
10 recheckDelayMs?: number;
11 retryBaseDelayMs?: number;
12 retryMaxDelayMs?: number;
13+ successCooldownMs?: number;
14 }
15
16 export function createRenewalDispatcherRunner(
17@@ -305,7 +307,14 @@ export async function runRenewalDispatcher(
18 });
19 const finishedAt = now();
20 const attemptCount = job.attemptCount + 1;
21+ const cooldownUntil = finishedAt + resolveSuccessCooldownMs(options.successCooldownMs, context.config.intervalMs);
22
23+ await artifactStore.upsertLocalConversation({
24+ cooldownUntil,
25+ localConversationId: job.localConversationId,
26+ platform: dispatchContext.conversation.platform,
27+ updatedAt: finishedAt
28+ });
29 await artifactStore.updateRenewalJob({
30 attemptCount,
31 finishedAt,
32@@ -792,6 +801,14 @@ function resolvePositiveInteger(value: number | undefined, fallback: number): nu
33 return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback;
34 }
35
36+function resolveSuccessCooldownMs(value: number | undefined, intervalMs: number): number {
37+ if (Number.isInteger(value) && Number(value) > 0) {
38+ return Number(value);
39+ }
40+
41+ return Math.max(DEFAULT_SUCCESS_COOLDOWN_MS, intervalMs + 1);
42+}
43+
44 function sanitizePathSegment(value: string): string {
45 const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-");
46 const collapsed = normalized.replace(/-+/gu, "-").replace(/^-|-$/gu, "");