baa-conductor

git clone 

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
M apps/conductor-daemon/src/index.test.js
+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");
M apps/conductor-daemon/src/renewal/dispatcher.ts
+17, -0
 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, "");