- commit
- 8ac471c
- parent
- 74f14ea
- author
- codex@macbookpro
- date
- 2026-04-01 16:54:22 +0800 CST
feat: persist browser request risk state
16 files changed,
+1184,
-39
1@@ -1,3 +1,5 @@
2+import type { ArtifactStore } from "../../../packages/artifact-db/dist/index.js";
3+
4 export type BrowserRequestCircuitState = "closed" | "half_open" | "open";
5 export type BrowserRequestLeaseStatus = "cancelled" | "failure" | "success";
6
7@@ -95,6 +97,42 @@ export interface BrowserRequestPolicySnapshot {
8 }>;
9 }
10
11+export interface BrowserRequestPolicyPersistentPlatformState {
12+ dispatches: number[];
13+ lastDispatchedAt: number | null;
14+ platform: string;
15+}
16+
17+export interface BrowserRequestPolicyPersistentTargetState {
18+ backoffUntil: number | null;
19+ circuitRetryAt: number | null;
20+ circuitState: BrowserRequestCircuitState;
21+ clientId: string;
22+ consecutiveFailures: number;
23+ lastActivityAt: number | null;
24+ lastActivityReason: string | null;
25+ lastError: string | null;
26+ lastFailureAt: number | null;
27+ lastStaleSweepAt: number | null;
28+ lastStaleSweepIdleMs: number | null;
29+ lastStaleSweepReason: string | null;
30+ lastStaleSweepRequestId: string | null;
31+ lastSuccessAt: number | null;
32+ platform: string;
33+ staleSweepCount: number;
34+}
35+
36+export interface BrowserRequestPolicyPersistentState {
37+ platforms: BrowserRequestPolicyPersistentPlatformState[];
38+ targets: BrowserRequestPolicyPersistentTargetState[];
39+ version: 1;
40+}
41+
42+export interface BrowserRequestPolicyPersistence {
43+ load(): Promise<BrowserRequestPolicyPersistentState | null>;
44+ save(state: BrowserRequestPolicyPersistentState): Promise<void>;
45+}
46+
47 export interface BrowserRequestPolicyLease {
48 readonly admission: BrowserRequestAdmission;
49 readonly target: BrowserRequestTarget;
50@@ -106,6 +144,8 @@ export interface BrowserRequestPolicyControllerOptions {
51 clearTimeoutImpl?: (handle: TimeoutHandle) => void;
52 config?: Partial<BrowserRequestPolicyConfig>;
53 now?: () => number;
54+ persistence?: BrowserRequestPolicyPersistence | null;
55+ persistenceDebounceMs?: number;
56 random?: () => number;
57 setTimeoutImpl?: (handler: () => void, timeoutMs: number) => TimeoutHandle;
58 }
59@@ -180,6 +220,8 @@ const DEFAULT_BROWSER_REQUEST_POLICY: BrowserRequestPolicyConfig = {
60 };
61
62 const BROWSER_REQUEST_WAITER_TIMEOUT_MS = 120_000;
63+const DEFAULT_BROWSER_REQUEST_POLICY_PERSISTENCE_DEBOUNCE_MS = 25;
64+const DEFAULT_BROWSER_REQUEST_POLICY_STATE_KEY = "global";
65
66 function clonePolicyConfig(input: BrowserRequestPolicyConfig): BrowserRequestPolicyConfig {
67 return {
68@@ -262,6 +304,30 @@ function normalizeOptionalString(value: string | null | undefined): string | nul
69 return normalized === "" ? null : normalized;
70 }
71
72+function normalizeOptionalInteger(value: unknown): number | null {
73+ return typeof value === "number" && Number.isInteger(value) ? value : null;
74+}
75+
76+function normalizeIntegerList(value: unknown): number[] {
77+ if (!Array.isArray(value)) {
78+ return [];
79+ }
80+
81+ return value.filter((entry): entry is number => typeof entry === "number" && Number.isInteger(entry));
82+}
83+
84+function readOptionalStringValue(value: unknown): string | null {
85+ return typeof value === "string" ? normalizeOptionalString(value) : null;
86+}
87+
88+function asRecord(value: unknown): Record<string, unknown> | null {
89+ if (value == null || typeof value !== "object" || Array.isArray(value)) {
90+ return null;
91+ }
92+
93+ return value as Record<string, unknown>;
94+}
95+
96 function buildTargetKey(target: BrowserRequestTarget): string {
97 return `${target.clientId}\u0000${target.platform}`;
98 }
99@@ -343,6 +409,13 @@ export class BrowserRequestPolicyController {
100 private nextLeaseSequence = 1;
101 private readonly now: () => number;
102 private readonly platforms = new Map<string, BrowserRequestPlatformState>();
103+ private persistence: BrowserRequestPolicyPersistence | null;
104+ private readonly persistenceDebounceMs: number;
105+ private persistenceDirty = false;
106+ private persistenceFlushPromise: Promise<void> | null = null;
107+ private persistenceFlushTimer: TimeoutHandle | null = null;
108+ private persistenceInitialized = false;
109+ private persistenceInitializePromise: Promise<void> | null = null;
110 private readonly random: () => number;
111 private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
112 private staleLeaseSweepTimer: TimeoutHandle | null = null;
113@@ -354,12 +427,40 @@ export class BrowserRequestPolicyController {
114 );
115 this.clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => globalThis.clearTimeout(handle));
116 this.now = options.now ?? (() => Date.now());
117+ this.persistence = options.persistence ?? null;
118+ this.persistenceDebounceMs = Math.max(
119+ 0,
120+ options.persistenceDebounceMs ?? DEFAULT_BROWSER_REQUEST_POLICY_PERSISTENCE_DEBOUNCE_MS
121+ );
122 this.random = options.random ?? (() => Math.random());
123 this.setTimeoutImpl =
124 options.setTimeoutImpl ?? ((handler, timeoutMs) => globalThis.setTimeout(handler, timeoutMs));
125 this.scheduleNextStaleLeaseSweep();
126 }
127
128+ async initialize(): Promise<void> {
129+ await this.ensurePersistenceInitialized();
130+ }
131+
132+ async flush(): Promise<void> {
133+ await this.ensurePersistenceInitialized();
134+
135+ if (this.persistence == null) {
136+ return;
137+ }
138+
139+ if (this.persistenceFlushTimer != null) {
140+ this.clearTimeoutImpl(this.persistenceFlushTimer);
141+ this.persistenceFlushTimer = null;
142+ }
143+
144+ if (this.persistenceFlushPromise != null) {
145+ await this.persistenceFlushPromise;
146+ }
147+
148+ await this.flushPersistentState();
149+ }
150+
151 getConfig(): BrowserRequestPolicyConfig {
152 return clonePolicyConfig(this.config);
153 }
154@@ -417,6 +518,8 @@ export class BrowserRequestPolicyController {
155 target: BrowserRequestTarget,
156 requestId: string
157 ): Promise<BrowserRequestPolicyLease> {
158+ await this.ensurePersistenceInitialized();
159+
160 const normalizedTarget = this.normalizeTarget(target);
161 const requestedAt = this.now();
162 const targetState = this.getTargetState(normalizedTarget);
163@@ -443,6 +546,194 @@ export class BrowserRequestPolicyController {
164 );
165 }
166
167+ private async ensurePersistenceInitialized(): Promise<void> {
168+ if (this.persistence == null || this.persistenceInitialized) {
169+ return;
170+ }
171+
172+ if (this.persistenceInitializePromise != null) {
173+ await this.persistenceInitializePromise;
174+ return;
175+ }
176+
177+ this.persistenceInitializePromise = (async () => {
178+ try {
179+ const loadedState = await this.persistence?.load();
180+
181+ if (loadedState != null) {
182+ this.restorePersistentState(loadedState);
183+ }
184+
185+ this.persistenceInitialized = true;
186+ } finally {
187+ this.persistenceInitializePromise = null;
188+ }
189+ })();
190+
191+ await this.persistenceInitializePromise;
192+ }
193+
194+ private restorePersistentState(state: BrowserRequestPolicyPersistentState): void {
195+ const now = this.now();
196+ const platforms = Array.isArray(state.platforms) ? state.platforms : [];
197+ const targets = Array.isArray(state.targets) ? state.targets : [];
198+
199+ for (const entry of platforms) {
200+ const platform = normalizeOptionalString(entry.platform);
201+
202+ if (platform == null) {
203+ continue;
204+ }
205+
206+ const restored: BrowserRequestPlatformState = {
207+ busy: false,
208+ dispatches: normalizeIntegerList(entry.dispatches).sort((left, right) => left - right),
209+ lastDispatchedAt: normalizeOptionalInteger(entry.lastDispatchedAt),
210+ waiters: []
211+ };
212+ prunePlatformDispatches(restored, now, this.config.rateLimit.windowMs);
213+ this.platforms.set(platform, restored);
214+ }
215+
216+ for (const entry of targets) {
217+ const clientId = normalizeOptionalString(entry.clientId);
218+ const platform = normalizeOptionalString(entry.platform);
219+
220+ if (clientId == null || platform == null) {
221+ continue;
222+ }
223+
224+ const key = buildTargetKey({
225+ clientId,
226+ platform
227+ });
228+ this.targets.set(key, {
229+ backoffUntil: normalizeOptionalInteger(entry.backoffUntil),
230+ circuitRetryAt: normalizeOptionalInteger(entry.circuitRetryAt),
231+ circuitState: entry.circuitState === "half_open" || entry.circuitState === "open"
232+ ? entry.circuitState
233+ : "closed",
234+ consecutiveFailures: Math.max(0, normalizeOptionalInteger(entry.consecutiveFailures) ?? 0),
235+ inFlight: 0,
236+ lastActivityAt: normalizeOptionalInteger(entry.lastActivityAt),
237+ lastActivityReason: normalizeOptionalString(entry.lastActivityReason),
238+ lastError: normalizeOptionalString(entry.lastError),
239+ lastFailureAt: normalizeOptionalInteger(entry.lastFailureAt),
240+ lastStaleSweepAt: normalizeOptionalInteger(entry.lastStaleSweepAt),
241+ lastStaleSweepIdleMs: normalizeOptionalInteger(entry.lastStaleSweepIdleMs),
242+ lastStaleSweepReason: normalizeOptionalString(entry.lastStaleSweepReason),
243+ lastStaleSweepRequestId: normalizeOptionalString(entry.lastStaleSweepRequestId),
244+ lastSuccessAt: normalizeOptionalInteger(entry.lastSuccessAt),
245+ leases: new Map(),
246+ staleSweepCount: Math.max(0, normalizeOptionalInteger(entry.staleSweepCount) ?? 0),
247+ waiters: []
248+ });
249+ }
250+ }
251+
252+ private markPersistentStateDirty(): void {
253+ if (this.persistence == null) {
254+ return;
255+ }
256+
257+ this.persistenceDirty = true;
258+ this.schedulePersistentFlush();
259+ }
260+
261+ private schedulePersistentFlush(): void {
262+ if (
263+ this.persistence == null
264+ || !this.persistenceInitialized
265+ || this.persistenceFlushPromise != null
266+ || this.persistenceFlushTimer != null
267+ ) {
268+ return;
269+ }
270+
271+ this.persistenceFlushTimer = this.setTimeoutImpl(() => {
272+ this.persistenceFlushTimer = null;
273+ void this.flushPersistentState().catch(() => {
274+ this.persistenceDirty = true;
275+ this.schedulePersistentFlush();
276+ });
277+ }, this.persistenceDebounceMs);
278+ maybeUnrefTimeout(this.persistenceFlushTimer);
279+ }
280+
281+ private async flushPersistentState(): Promise<void> {
282+ if (
283+ this.persistence == null
284+ || !this.persistenceInitialized
285+ || this.persistenceFlushPromise != null
286+ || !this.persistenceDirty
287+ ) {
288+ return;
289+ }
290+
291+ this.persistenceFlushPromise = (async () => {
292+ try {
293+ while (this.persistenceDirty) {
294+ this.persistenceDirty = false;
295+ await this.persistence?.save(this.buildPersistentState());
296+ }
297+ } finally {
298+ this.persistenceFlushPromise = null;
299+
300+ if (this.persistenceDirty) {
301+ this.schedulePersistentFlush();
302+ }
303+ }
304+ })();
305+
306+ await this.persistenceFlushPromise;
307+ }
308+
309+ private buildPersistentState(): BrowserRequestPolicyPersistentState {
310+ const now = this.now();
311+ const platforms = [...this.platforms.entries()]
312+ .map(([platform, state]) => {
313+ prunePlatformDispatches(state, now, this.config.rateLimit.windowMs);
314+ return {
315+ dispatches: [...state.dispatches],
316+ lastDispatchedAt: state.lastDispatchedAt,
317+ platform
318+ };
319+ })
320+ .sort((left, right) => left.platform.localeCompare(right.platform));
321+ const targets = [...this.targets.entries()]
322+ .map(([key, state]) => {
323+ const [clientId, platform] = key.split("\u0000");
324+ return {
325+ backoffUntil: state.backoffUntil,
326+ circuitRetryAt: state.circuitRetryAt,
327+ circuitState: state.circuitState,
328+ clientId: clientId ?? "",
329+ consecutiveFailures: state.consecutiveFailures,
330+ lastActivityAt: state.lastActivityAt,
331+ lastActivityReason: state.lastActivityReason,
332+ lastError: state.lastError,
333+ lastFailureAt: state.lastFailureAt,
334+ lastStaleSweepAt: state.lastStaleSweepAt,
335+ lastStaleSweepIdleMs: state.lastStaleSweepIdleMs,
336+ lastStaleSweepReason: state.lastStaleSweepReason,
337+ lastStaleSweepRequestId: state.lastStaleSweepRequestId,
338+ lastSuccessAt: state.lastSuccessAt,
339+ platform: platform ?? "",
340+ staleSweepCount: state.staleSweepCount
341+ };
342+ })
343+ .sort((left, right) => {
344+ const clientCompare = left.clientId.localeCompare(right.clientId);
345+ return clientCompare === 0 ? left.platform.localeCompare(right.platform) : clientCompare;
346+ });
347+
348+ return {
349+ platforms,
350+ targets,
351+ version: 1
352+ };
353+ }
354+
355 private normalizeTarget(target: BrowserRequestTarget): BrowserRequestTarget {
356 const clientId = normalizeOptionalString(target.clientId);
357 const platform = normalizeOptionalString(target.platform);
358@@ -745,6 +1036,7 @@ export class BrowserRequestPolicyController {
359 prunePlatformDispatches(platformState, admittedAt, this.config.rateLimit.windowMs);
360 platformState.dispatches.push(admittedAt);
361 platformState.lastDispatchedAt = admittedAt;
362+ this.markPersistentStateDirty();
363
364 return {
365 admittedAt,
366@@ -790,6 +1082,7 @@ export class BrowserRequestPolicyController {
367 }
368
369 state.circuitState = "half_open";
370+ this.markPersistentStateDirty();
371 }
372
373 private sampleJitterDelayMs(): number {
374@@ -819,6 +1112,7 @@ export class BrowserRequestPolicyController {
375 state.lastError = null;
376 state.lastSuccessAt = now;
377 this.recordTargetActivity(state, now, "complete_success");
378+ this.markPersistentStateDirty();
379 this.releaseTargetLease(state, leaseId);
380 return;
381 }
382@@ -847,6 +1141,7 @@ export class BrowserRequestPolicyController {
383 }
384
385 this.recordTargetActivity(state, now, "complete_failure");
386+ this.markPersistentStateDirty();
387 this.releaseTargetLease(state, leaseId);
388 }
389
390@@ -914,10 +1209,113 @@ export class BrowserRequestPolicyController {
391 state.lastStaleSweepReason = `${reason}:lease_idle_timeout`;
392 state.lastStaleSweepRequestId = lease.requestId;
393 this.recordTargetActivity(state, now, "stale_sweep");
394+ this.markPersistentStateDirty();
395 this.releaseTargetLease(state, leaseId);
396 }
397 }
398
399+function parsePersistentStateJson(valueJson: string): BrowserRequestPolicyPersistentState | null {
400+ try {
401+ const parsed = asRecord(JSON.parse(valueJson) as unknown);
402+
403+ if (parsed == null) {
404+ return null;
405+ }
406+
407+ const version = parsed.version === 1 ? 1 : null;
408+
409+ if (version == null) {
410+ return null;
411+ }
412+
413+ const platforms = Array.isArray(parsed.platforms)
414+ ? parsed.platforms
415+ .map((entry) => {
416+ const record = asRecord(entry);
417+ const platform = readOptionalStringValue(record?.platform);
418+
419+ if (record == null || platform == null) {
420+ return null;
421+ }
422+
423+ return {
424+ dispatches: normalizeIntegerList(record.dispatches),
425+ lastDispatchedAt: normalizeOptionalInteger(record.lastDispatchedAt),
426+ platform
427+ };
428+ })
429+ .filter((entry): entry is BrowserRequestPolicyPersistentPlatformState => entry != null)
430+ : [];
431+ const targets = Array.isArray(parsed.targets)
432+ ? parsed.targets
433+ .map((entry) => {
434+ const record = asRecord(entry);
435+ const clientId = readOptionalStringValue(record?.clientId);
436+ const platform = readOptionalStringValue(record?.platform);
437+
438+ if (record == null || clientId == null || platform == null) {
439+ return null;
440+ }
441+
442+ return {
443+ backoffUntil: normalizeOptionalInteger(record.backoffUntil),
444+ circuitRetryAt: normalizeOptionalInteger(record.circuitRetryAt),
445+ circuitState: record.circuitState === "half_open" || record.circuitState === "open"
446+ ? record.circuitState
447+ : "closed",
448+ clientId,
449+ consecutiveFailures: Math.max(0, normalizeOptionalInteger(record.consecutiveFailures) ?? 0),
450+ lastActivityAt: normalizeOptionalInteger(record.lastActivityAt),
451+ lastActivityReason: readOptionalStringValue(record.lastActivityReason),
452+ lastError: readOptionalStringValue(record.lastError),
453+ lastFailureAt: normalizeOptionalInteger(record.lastFailureAt),
454+ lastStaleSweepAt: normalizeOptionalInteger(record.lastStaleSweepAt),
455+ lastStaleSweepIdleMs: normalizeOptionalInteger(record.lastStaleSweepIdleMs),
456+ lastStaleSweepReason: readOptionalStringValue(record.lastStaleSweepReason),
457+ lastStaleSweepRequestId: readOptionalStringValue(record.lastStaleSweepRequestId),
458+ lastSuccessAt: normalizeOptionalInteger(record.lastSuccessAt),
459+ platform,
460+ staleSweepCount: Math.max(0, normalizeOptionalInteger(record.staleSweepCount) ?? 0)
461+ };
462+ })
463+ .filter((entry): entry is BrowserRequestPolicyPersistentTargetState => entry != null)
464+ : [];
465+
466+ return {
467+ platforms,
468+ targets,
469+ version
470+ };
471+ } catch {
472+ return null;
473+ }
474+}
475+
476+export function createArtifactStoreBrowserRequestPolicyPersistence(
477+ artifactStore: Pick<ArtifactStore, "getBrowserRequestPolicyState" | "upsertBrowserRequestPolicyState">,
478+ options: {
479+ now?: () => number;
480+ stateKey?: string;
481+ } = {}
482+): BrowserRequestPolicyPersistence {
483+ const now = options.now ?? (() => Date.now());
484+ const stateKey = normalizeOptionalString(options.stateKey) ?? DEFAULT_BROWSER_REQUEST_POLICY_STATE_KEY;
485+
486+ return {
487+ async load() {
488+ const record = await artifactStore.getBrowserRequestPolicyState(stateKey);
489+ return record == null ? null : parsePersistentStateJson(record.valueJson);
490+ },
491+ async save(state) {
492+ await artifactStore.upsertBrowserRequestPolicyState({
493+ stateKey,
494+ updatedAt: now(),
495+ valueJson: JSON.stringify(state)
496+ });
497+ }
498+ };
499+}
500+
501 export function createDefaultBrowserRequestPolicyConfig(): BrowserRequestPolicyConfig {
502 return clonePolicyConfig(DEFAULT_BROWSER_REQUEST_POLICY);
503 }
+247,
-0
1@@ -30,6 +30,7 @@ import {
2 PersistentBaaInstructionDeduper,
3 PersistentBaaLiveInstructionMessageDeduper,
4 PersistentBaaLiveInstructionSnapshotStore,
5+ createArtifactStoreBrowserRequestPolicyPersistence,
6 createFetchControlApiClient,
7 createRenewalDispatcherRunner,
8 createRenewalProjectorRunner,
9@@ -6914,6 +6915,168 @@ test("handleConductorHttpRequest exposes stale lease sweep diagnostics in /v1/br
10 });
11 });
12
13+test("BrowserRequestPolicyController restores persisted circuit state from artifact.db", async () => {
14+ const scheduler = createManualTimerScheduler();
15+
16+ await withArtifactStoreFixture(async ({ artifactStore }) => {
17+ const persistence = createArtifactStoreBrowserRequestPolicyPersistence(artifactStore, {
18+ now: scheduler.now
19+ });
20+ const firstPolicy = new BrowserRequestPolicyController({
21+ clearTimeoutImpl: scheduler.clearTimeout,
22+ config: {
23+ circuitBreaker: {
24+ failureThreshold: 1,
25+ openMs: 60_000
26+ },
27+ jitter: {
28+ maxMs: 0,
29+ minMs: 0,
30+ muMs: 0,
31+ sigmaMs: 0
32+ }
33+ },
34+ now: scheduler.now,
35+ persistence,
36+ setTimeoutImpl: scheduler.setTimeout
37+ });
38+
39+ await firstPolicy.initialize();
40+
41+ const failedLease = await firstPolicy.beginRequest({
42+ clientId: "firefox-claude",
43+ platform: "claude"
44+ }, "request-persisted-circuit");
45+
46+ failedLease.complete({
47+ code: "upstream_429",
48+ status: "failure"
49+ });
50+ await firstPolicy.flush();
51+
52+ const persistedState = await artifactStore.getBrowserRequestPolicyState("global");
53+ assert.ok(persistedState);
54+
55+ const restartedPolicy = new BrowserRequestPolicyController({
56+ clearTimeoutImpl: scheduler.clearTimeout,
57+ config: {
58+ circuitBreaker: {
59+ failureThreshold: 1,
60+ openMs: 60_000
61+ },
62+ jitter: {
63+ maxMs: 0,
64+ minMs: 0,
65+ muMs: 0,
66+ sigmaMs: 0
67+ }
68+ },
69+ now: scheduler.now,
70+ persistence,
71+ setTimeoutImpl: scheduler.setTimeout
72+ });
73+
74+ await restartedPolicy.initialize();
75+
76+ assert.equal(getPolicyPlatformSnapshot(restartedPolicy, "claude")?.recentDispatchCount, 1);
77+
78+ await assert.rejects(
79+ restartedPolicy.beginRequest({
80+ clientId: "firefox-claude",
81+ platform: "claude"
82+ }, "request-after-restart"),
83+ (error) => {
84+ assert.equal(error.code, "circuit_open");
85+ assert.equal(error.details.retry_after_ms, 60_000);
86+ return true;
87+ }
88+ );
89+ });
90+});
91+
92+test("BrowserRequestPolicyController restores persisted rate-limit windows from artifact.db", async () => {
93+ const scheduler = createManualTimerScheduler();
94+
95+ await withArtifactStoreFixture(async ({ artifactStore }) => {
96+ const persistence = createArtifactStoreBrowserRequestPolicyPersistence(artifactStore, {
97+ now: scheduler.now
98+ });
99+ const firstPolicy = new BrowserRequestPolicyController({
100+ clearTimeoutImpl: scheduler.clearTimeout,
101+ config: {
102+ jitter: {
103+ maxMs: 0,
104+ minMs: 0,
105+ muMs: 0,
106+ sigmaMs: 0
107+ },
108+ rateLimit: {
109+ requestsPerMinutePerPlatform: 1,
110+ windowMs: 60_000
111+ }
112+ },
113+ now: scheduler.now,
114+ persistence,
115+ setTimeoutImpl: scheduler.setTimeout
116+ });
117+
118+ await firstPolicy.initialize();
119+
120+ const firstLease = await firstPolicy.beginRequest({
121+ clientId: "firefox-chatgpt",
122+ platform: "chatgpt"
123+ }, "request-persisted-rate-limit");
124+
125+ firstLease.complete({
126+ status: "success"
127+ });
128+ await firstPolicy.flush();
129+
130+ const restartedPolicy = new BrowserRequestPolicyController({
131+ clearTimeoutImpl: scheduler.clearTimeout,
132+ config: {
133+ jitter: {
134+ maxMs: 0,
135+ minMs: 0,
136+ muMs: 0,
137+ sigmaMs: 0
138+ },
139+ rateLimit: {
140+ requestsPerMinutePerPlatform: 1,
141+ windowMs: 60_000
142+ }
143+ },
144+ now: scheduler.now,
145+ persistence,
146+ setTimeoutImpl: scheduler.setTimeout
147+ });
148+
149+ await restartedPolicy.initialize();
150+
151+ const delayedLeasePromise = restartedPolicy.beginRequest({
152+ clientId: "firefox-chatgpt",
153+ platform: "chatgpt"
154+ }, "request-after-rate-limit-restart");
155+ let delayedResolved = false;
156+
157+ void delayedLeasePromise.then(() => {
158+ delayedResolved = true;
159+ });
160+
161+ await flushAsyncWork();
162+ assert.equal(delayedResolved, false);
163+ assert.equal(getPolicyPlatformSnapshot(restartedPolicy, "chatgpt")?.recentDispatchCount, 1);
164+
165+ scheduler.advanceBy(60_000);
166+
167+ const delayedLease = await delayedLeasePromise;
168+ assert.equal(delayedLease.admission.rateLimitDelayMs, 60_000);
169+ delayedLease.complete({
170+ status: "cancelled"
171+ });
172+ });
173+});
174+
175 test(
176 "handleConductorHttpRequest normalizes exec failures that are blocked by macOS TCC preflight",
177 { concurrency: false },
178@@ -9075,6 +9238,90 @@ test("ConductorRuntime exposes renewal jobs APIs and registers the renewal dispa
179 }
180 });
181
182+test("ConductorRuntime startup recovers stale automation locks and running renewal jobs", async () => {
183+ const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-recovery-"));
184+ const artifactStore = new ArtifactStore({
185+ artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
186+ databasePath: join(stateDir, ARTIFACT_DB_FILENAME)
187+ });
188+ const nowMs = 100_000;
189+ let runtime = null;
190+
191+ try {
192+ await artifactStore.insertMessage({
193+ conversationId: "conv-runtime-recovery",
194+ id: "msg-runtime-recovery",
195+ observedAt: nowMs - 30_000,
196+ platform: "claude",
197+ rawText: "runtime recovery message",
198+ role: "assistant"
199+ });
200+ await artifactStore.upsertLocalConversation({
201+ automationStatus: "auto",
202+ executionState: "renewal_running",
203+ localConversationId: "lc-runtime-recovery",
204+ platform: "claude",
205+ updatedAt: nowMs - 20_000
206+ });
207+ await artifactStore.insertRenewalJob({
208+ attemptCount: 1,
209+ createdAt: nowMs - 10_000,
210+ jobId: "job-runtime-recovery",
211+ localConversationId: "lc-runtime-recovery",
212+ messageId: "msg-runtime-recovery",
213+ nextAttemptAt: null,
214+ payload: "[renew]",
215+ startedAt: nowMs - 10_000,
216+ status: "running",
217+ updatedAt: nowMs - 10_000
218+ });
219+ } finally {
220+ artifactStore.close();
221+ }
222+
223+ try {
224+ runtime = new ConductorRuntime(
225+ {
226+ nodeId: "mini-main",
227+ host: "mini",
228+ role: "primary",
229+ controlApiBase: "https://control.example.test",
230+ localApiBase: "http://127.0.0.1:0",
231+ sharedToken: "replace-me",
232+ paths: {
233+ runsDir: "/tmp/runs",
234+ stateDir
235+ }
236+ },
237+ {
238+ autoStartLoops: false,
239+ now: () => 100
240+ }
241+ );
242+
243+ await runtime.start();
244+
245+ const recoveredStore = runtime["artifactStore"];
246+ const conversation = await recoveredStore.getLocalConversation("lc-runtime-recovery");
247+ const job = await recoveredStore.getRenewalJob("job-runtime-recovery");
248+
249+ assert.equal(conversation.executionState, "idle");
250+ assert.equal(job.status, "pending");
251+ assert.equal(job.startedAt, null);
252+ assert.equal(job.finishedAt, null);
253+ assert.equal(job.nextAttemptAt, 160_000);
254+ } finally {
255+ if (runtime != null) {
256+ await runtime.stop();
257+ }
258+
259+ rmSync(stateDir, {
260+ force: true,
261+ recursive: true
262+ });
263+ }
264+});
265+
266 test("ConductorRuntime registers renewal projector, projects auto messages once, and keeps cursor across restart", async () => {
267 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-projector-runtime-"));
268 const logsDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-projector-runtime-logs-"));
+18,
-1
1@@ -34,6 +34,7 @@ import {
2 import { DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD } from "./artifacts/upload-session.js";
3 import {
4 BrowserRequestPolicyController,
5+ createArtifactStoreBrowserRequestPolicyPersistence,
6 type BrowserRequestPolicyControllerOptions
7 } from "./browser-request-policy.js";
8 import type { BrowserBridgeController } from "./browser-types.js";
9@@ -70,6 +71,7 @@ export {
10 } from "./firefox-bridge.js";
11 export {
12 BrowserRequestPolicyController,
13+ createArtifactStoreBrowserRequestPolicyPersistence,
14 type BrowserRequestPolicyControllerOptions
15 } from "./browser-request-policy.js";
16 export { handleConductorHttpRequest } from "./local-api.js";
17@@ -97,6 +99,7 @@ const DEFAULT_TIMED_JOBS_INTERVAL_MS = 10_000;
18 const DEFAULT_TIMED_JOBS_MAX_MESSAGES_PER_TICK = 10;
19 const DEFAULT_TIMED_JOBS_MAX_TASKS_PER_TICK = 10;
20 const DEFAULT_TIMED_JOBS_SETTLE_DELAY_MS = 10_000;
21+const DEFAULT_RESTART_RENEWAL_RECOVERY_DELAY_MS = 60_000;
22
23 const STARTUP_CHECKLIST: StartupChecklistItem[] = [
24 { key: "register-controller", description: "注册 controller 并写入初始 heartbeat" },
25@@ -782,7 +785,11 @@ class ConductorLocalHttpServer {
26 pluginDiagnosticLogDir: string | null = null
27 ) {
28 this.artifactStore = artifactStore;
29- this.browserRequestPolicy = new BrowserRequestPolicyController(browserRequestPolicyOptions);
30+ this.browserRequestPolicy = new BrowserRequestPolicyController({
31+ ...browserRequestPolicyOptions,
32+ persistence: browserRequestPolicyOptions.persistence
33+ ?? createArtifactStoreBrowserRequestPolicyPersistence(this.artifactStore)
34+ });
35 this.claudeCodedLocalApiBase = claudeCodedLocalApiBase;
36 this.codeRootDir = codeRootDir;
37 this.codexdLocalApiBase = codexdLocalApiBase;
38@@ -853,6 +860,7 @@ class ConductorLocalHttpServer {
39 return this.resolvedBaseUrl;
40 }
41
42+ await this.browserRequestPolicy.initialize();
43 await this.instructionIngest.initialize();
44
45 const listenConfig = resolveLocalApiListenConfig(this.localApiBase);
46@@ -959,6 +967,7 @@ class ConductorLocalHttpServer {
47 const server = this.server;
48 this.server = null;
49 await this.firefoxWebSocketServer.stop();
50+ await this.browserRequestPolicy.flush();
51
52 await new Promise<void>((resolve, reject) => {
53 server.close((error) => {
54@@ -2387,6 +2396,14 @@ export class ConductorRuntime {
55 this.localControlPlaneInitialized = true;
56 }
57
58+ await this.artifactStore.recoverAutomationRuntimeState({
59+ now: this.now() * 1000,
60+ renewalRecoveryDelayMs: Math.max(
61+ DEFAULT_RESTART_RENEWAL_RECOVERY_DELAY_MS,
62+ this.config.timedJobsIntervalMs
63+ )
64+ });
65+
66 await this.daemon.start();
67
68 try {
+2,
-0
1@@ -86,6 +86,7 @@
2 - `stream_event` 都带递增 `seq`;失败、超时或取消时会带着已收到的 partial 状态落到 `stream_error`
3 - `GET /v1/browser` 会返回当前浏览器风控默认值和运行中 target/platform 状态摘要,便于观察限流、退避和熔断
4 - `GET /v1/browser` 的 `policy.defaults.stale_lease` 会暴露后台清扫阈值;`policy.targets[]` 会补 `last_activity_*`、`stale_sweep_count`、`last_stale_sweep_*`,便于判断 slot 是否曾被后台自愈回收
5+- `GET /v1/browser` 的 `policy` 关键运行态现在会从 `artifact.db` 恢复限流窗口、退避和熔断状态;`in_flight` / `waiting` 仍然只是当前进程内视图
6 - `send` / `current` 不是 DOM 自动化,而是通过插件已有的页面内 HTTP 代理完成
7 - renewal 读面当前分成三层:
8 - `conversations` 看自动化状态和 active link
9@@ -97,6 +98,7 @@
10 - `consecutive_failure_count`
11 - `repeated_message_count`
12 - `repeated_renewal_count`
13+- `conductor` 启动时会自动释放异常退出遗留的 `execution_state`,并把 `status=running` 的 renewal job 安全回排为 `pending`
14 - ChatGPT raw relay 仍依赖浏览器里真实捕获到的登录态 / header;建议先看 `GET /v1/browser?platform=chatgpt&status=fresh`
15 - 如果没有活跃 Firefox bridge client,会返回 `503`
16 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
+2,
-0
1@@ -103,6 +103,7 @@ browser/plugin 管理约定:
2 - `POST /v1/browser/actions` 会等待插件回传结构化 `action_result`,返回 `accepted` / `completed` / `failed` / `reason` / `target` / `result` / `shell_runtime`
3 - `GET /v1/browser` 会同步暴露当前 `shell_runtime` 和每个 client 最近一次结构化 `action_result`
4 - `GET /v1/browser` 的 `policy` 视图也会带 `stale_lease` 默认阈值,以及 target 级别的 `last_activity_*`、`stale_sweep_count`、`last_stale_sweep_*` 诊断字段
5+- browser request 的限流窗口、退避和熔断状态现在会持久化到 `artifact.db`;daemon 重启后 `GET /v1/browser` 会恢复上次的风险控制位置,`in_flight / waiting` 仍只代表当前进程内的运行态
6 - browser 业务请求不在本节;请改读 [`business-interfaces.md`](./business-interfaces.md) 和 `POST /v1/browser/request`
7
8 ### 控制动作
9@@ -136,6 +137,7 @@ system 控制约定:
10 - `paused` 不会删除任务,只会阻止 dispatcher 继续推进待执行 job
11 - `manual` 和 `auto` / `paused` 共用同一份 `local_conversations` 后端状态,不存在插件侧单独影子开关
12 - renewal REST 写接口修改状态后,Firefox bridge 的 `state_snapshot.browser.automation_conversations` 会在下一轮 WS 推送中同步更新,供浮层实时刷新
13+- conductor 启动时会自动清理上次异常退出遗留的 `execution_state`,并把 `status=running` 的 renewal job 重新排回 `pending`
14 - `GET /v1/renewal/conversations/:local_conversation_id` 现在会额外暴露:
15 - `pause_reason`
16 - `last_error`
+139,
-0
1@@ -342,6 +342,145 @@ test("ArtifactStore persists renewal storage records and enqueues sync payloads"
2 }
3 });
4
5+test("ArtifactStore persists browser request policy state and enqueues sync payloads", async () => {
6+ const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-browser-policy-state-test-"));
7+ const stateDir = join(rootDir, "state");
8+ const databasePath = join(stateDir, ARTIFACT_DB_FILENAME);
9+ const artifactDir = join(stateDir, ARTIFACTS_DIRNAME);
10+ const store = new ArtifactStore({
11+ artifactDir,
12+ databasePath
13+ });
14+ const syncRecords = [];
15+ store.setSyncQueue({
16+ enqueueSyncRecord(input) {
17+ syncRecords.push(input);
18+ }
19+ });
20+
21+ try {
22+ const persisted = await store.upsertBrowserRequestPolicyState({
23+ stateKey: "global",
24+ updatedAt: Date.UTC(2026, 2, 28, 9, 30, 0),
25+ valueJson: JSON.stringify({
26+ platforms: [
27+ {
28+ dispatches: [Date.UTC(2026, 2, 28, 9, 29, 0)],
29+ lastDispatchedAt: Date.UTC(2026, 2, 28, 9, 29, 0),
30+ platform: "claude"
31+ }
32+ ],
33+ targets: [],
34+ version: 1
35+ })
36+ });
37+
38+ assert.deepEqual(await store.getBrowserRequestPolicyState("global"), persisted);
39+ assert.deepEqual(
40+ syncRecords.map((record) => [record.tableName, record.operation, record.recordId]),
41+ [["browser_request_policy_state", "update", "global"]]
42+ );
43+ } finally {
44+ store.close();
45+ rmSync(rootDir, {
46+ force: true,
47+ recursive: true
48+ });
49+ }
50+});
51+
52+test("ArtifactStore runtime recovery clears stale execution locks and requeues running renewal jobs", async () => {
53+ const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-runtime-recovery-test-"));
54+ const stateDir = join(rootDir, "state");
55+ const databasePath = join(stateDir, ARTIFACT_DB_FILENAME);
56+ const artifactDir = join(stateDir, ARTIFACTS_DIRNAME);
57+ const store = new ArtifactStore({
58+ artifactDir,
59+ databasePath
60+ });
61+ const syncRecords = [];
62+ store.setSyncQueue({
63+ enqueueSyncRecord(input) {
64+ syncRecords.push(input);
65+ }
66+ });
67+
68+ try {
69+ const message = await store.insertMessage({
70+ conversationId: "conv_recovery",
71+ id: "msg_recovery",
72+ observedAt: Date.UTC(2026, 2, 28, 10, 0, 0),
73+ platform: "claude",
74+ rawText: "recovery message",
75+ role: "assistant"
76+ });
77+ await store.upsertLocalConversation({
78+ automationStatus: "auto",
79+ executionState: "renewal_running",
80+ localConversationId: "lc_recovery_busy",
81+ platform: "claude",
82+ updatedAt: Date.UTC(2026, 2, 28, 10, 1, 0)
83+ });
84+ await store.upsertLocalConversation({
85+ automationStatus: "auto",
86+ executionState: "instruction_running",
87+ localConversationId: "lc_recovery_instruction",
88+ platform: "claude",
89+ updatedAt: Date.UTC(2026, 2, 28, 10, 1, 30)
90+ });
91+ await store.insertRenewalJob({
92+ attemptCount: 1,
93+ createdAt: Date.UTC(2026, 2, 28, 10, 2, 0),
94+ jobId: "job_recovery_running",
95+ localConversationId: "lc_recovery_busy",
96+ messageId: message.id,
97+ nextAttemptAt: null,
98+ payload: "[renew]",
99+ startedAt: Date.UTC(2026, 2, 28, 10, 2, 0),
100+ status: "running",
101+ updatedAt: Date.UTC(2026, 2, 28, 10, 2, 0)
102+ });
103+
104+ const recovered = await store.recoverAutomationRuntimeState({
105+ now: Date.UTC(2026, 2, 28, 10, 3, 0),
106+ renewalRecoveryDelayMs: 45_000
107+ });
108+
109+ const recoveredBusyConversation = await store.getLocalConversation("lc_recovery_busy");
110+ const recoveredInstructionConversation = await store.getLocalConversation("lc_recovery_instruction");
111+ const recoveredJob = await store.getRenewalJob("job_recovery_running");
112+
113+ assert.deepEqual(recovered, {
114+ recoveredExecutionStateCount: 2,
115+ recoveryNextAttemptAt: Date.UTC(2026, 2, 28, 10, 3, 45),
116+ requeuedRenewalJobCount: 1
117+ });
118+ assert.equal(recoveredBusyConversation?.executionState, "idle");
119+ assert.equal(recoveredInstructionConversation?.executionState, "idle");
120+ assert.equal(recoveredJob?.status, "pending");
121+ assert.equal(recoveredJob?.startedAt, null);
122+ assert.equal(recoveredJob?.finishedAt, null);
123+ assert.equal(recoveredJob?.nextAttemptAt, Date.UTC(2026, 2, 28, 10, 3, 45));
124+ assert.deepEqual(
125+ syncRecords
126+ .filter((record) => record.operation === "update")
127+ .map((record) => [record.tableName, record.recordId])
128+ .sort(),
129+ [
130+ ["local_conversations", "lc_recovery_busy"],
131+ ["local_conversations", "lc_recovery_instruction"],
132+ ["renewal_jobs", "job_recovery_running"]
133+ ]
134+ );
135+ } finally {
136+ store.close();
137+ rmSync(rootDir, {
138+ force: true,
139+ recursive: true
140+ });
141+ }
142+});
143+
144 test("ArtifactStore UPSERT SQL preserves created_at for renewal storage rows on conflict", async () => {
145 const rootDir = mkdtempSync(join(tmpdir(), "artifact-db-created-at-upsert-test-"));
146 const stateDir = join(rootDir, "state");
+4,
-0
1@@ -15,7 +15,10 @@ export {
2 type ConversationAutomationExecutionState,
3 type ConversationAutomationStatus,
4 type ConversationPauseReason,
5+ type BrowserRequestPolicyStateRecord,
6 type ConversationLinkRecord,
7+ type RecoverAutomationRuntimeStateInput,
8+ type RecoverAutomationRuntimeStateResult,
9 DEFAULT_SESSION_INDEX_LIMIT,
10 DEFAULT_SUMMARY_LENGTH,
11 type ArtifactFileKind,
12@@ -45,6 +48,7 @@ export {
13 type SessionTimelineEntry,
14 type SyncEnqueuer,
15 type UpdateRenewalJobInput,
16+ type UpsertBrowserRequestPolicyStateInput,
17 type UpsertConversationLinkInput,
18 type UpsertLocalConversationInput,
19 type UpsertSessionInput
+9,
-0
1@@ -177,4 +177,13 @@ CREATE INDEX IF NOT EXISTS idx_renewal_jobs_status_due
2 ON renewal_jobs(status, next_attempt_at ASC, created_at ASC);
3 CREATE INDEX IF NOT EXISTS idx_renewal_jobs_local_conversation
4 ON renewal_jobs(local_conversation_id, status, created_at DESC);
5+
6+CREATE TABLE IF NOT EXISTS browser_request_policy_state (
7+ state_key TEXT PRIMARY KEY,
8+ value_json TEXT NOT NULL,
9+ updated_at INTEGER NOT NULL
10+);
11+
12+CREATE INDEX IF NOT EXISTS idx_browser_request_policy_state_updated
13+ ON browser_request_policy_state(updated_at DESC);
14 `;
+206,
-0
1@@ -21,12 +21,15 @@ import {
2 DEFAULT_SUMMARY_LENGTH,
3 type ArtifactStoreConfig,
4 type ArtifactTextFile,
5+ type BrowserRequestPolicyStateRecord,
6 type ConversationAutomationExecutionState,
7 type ConversationAutomationStatus,
8 type ConversationPauseReason,
9 type ConversationLinkRecord,
10 type ExecutionRecord,
11 type ExecutionParamsKind,
12+ type RecoverAutomationRuntimeStateInput,
13+ type RecoverAutomationRuntimeStateResult,
14 type InsertExecutionInput,
15 type InsertMessageInput,
16 type InsertRenewalJobInput,
17@@ -48,6 +51,7 @@ import {
18 type SessionTimelineEntry,
19 type SyncEnqueuer,
20 type UpdateRenewalJobInput,
21+ type UpsertBrowserRequestPolicyStateInput,
22 type UpsertConversationLinkInput,
23 type UpsertLocalConversationInput,
24 type UpsertSessionInput
25@@ -299,6 +303,43 @@ ON CONFLICT(job_id) DO UPDATE SET
26 updated_at = excluded.updated_at;
27 `;
28
29+const UPSERT_BROWSER_REQUEST_POLICY_STATE_SQL = `
30+INSERT INTO browser_request_policy_state (
31+ state_key,
32+ value_json,
33+ updated_at
34+) VALUES (?, ?, ?)
35+ON CONFLICT(state_key) DO UPDATE SET
36+ value_json = excluded.value_json,
37+ updated_at = excluded.updated_at;
38+`;
39+
40+const DEFAULT_RENEWAL_RECOVERY_DELAY_MS = 60_000;
41+const RECOVER_RUNNING_RENEWAL_JOBS_SQL = `
42+UPDATE renewal_jobs
43+SET
44+ status = 'pending',
45+ next_attempt_at = CASE
46+ WHEN next_attempt_at IS NOT NULL AND next_attempt_at > ? THEN next_attempt_at
47+ ELSE ?
48+ END,
49+ started_at = NULL,
50+ finished_at = NULL,
51+ updated_at = ?
52+WHERE status = 'running';
53+`;
54+
55+const RECOVER_LOCAL_CONVERSATION_EXECUTION_SQL = `
56+UPDATE local_conversations
57+SET
58+ execution_state = 'idle',
59+ updated_at = CASE
60+ WHEN updated_at > ? THEN updated_at
61+ ELSE ?
62+ END
63+WHERE execution_state != 'idle';
64+`;
65+
66 interface FileMutation {
67 existed: boolean;
68 path: string;
69@@ -413,6 +454,12 @@ interface RenewalJobRow {
70 updated_at: number;
71 }
72
73+interface BrowserRequestPolicyStateRow {
74+ state_key: string;
75+ value_json: string;
76+ updated_at: number;
77+}
78+
79 interface TimelineMessageRow {
80 id: string;
81 observed_at: number;
82@@ -580,6 +627,16 @@ export class ArtifactStore {
83 return row == null ? null : mapRenewalJobRow(row);
84 }
85
86+ async getBrowserRequestPolicyState(
87+ stateKey: string
88+ ): Promise<BrowserRequestPolicyStateRecord | null> {
89+ const row = this.getRow<BrowserRequestPolicyStateRow>(
90+ "SELECT * FROM browser_request_policy_state WHERE state_key = ? LIMIT 1;",
91+ normalizeRequiredString(stateKey, "stateKey")
92+ );
93+ return row == null ? null : mapBrowserRequestPolicyStateRow(row);
94+ }
95+
96 async insertExecution(input: InsertExecutionInput): Promise<ExecutionRecord> {
97 const record = buildExecutionRecord(input, this.summaryLength);
98 const message = await this.getMessage(record.messageId);
99@@ -758,6 +815,28 @@ export class ArtifactStore {
100 return record;
101 }
102
103+ async upsertBrowserRequestPolicyState(
104+ input: UpsertBrowserRequestPolicyStateInput
105+ ): Promise<BrowserRequestPolicyStateRecord> {
106+ const record = buildBrowserRequestPolicyStateRecord(input);
107+
108+ this.executeWrite(() => {
109+ this.run(
110+ UPSERT_BROWSER_REQUEST_POLICY_STATE_SQL,
111+ browserRequestPolicyStateParams(record)
112+ );
113+ });
114+
115+ this.enqueueSync(
116+ "browser_request_policy_state",
117+ record.stateKey,
118+ browserRequestPolicyStateSyncPayload(record),
119+ "update"
120+ );
121+
122+ return record;
123+ }
124+
125 async listExecutions(options: ListExecutionsOptions = {}): Promise<ExecutionRecord[]> {
126 const query = [
127 "SELECT * FROM executions",
128@@ -1014,6 +1093,93 @@ export class ArtifactStore {
129 return record;
130 }
131
132+ async recoverAutomationRuntimeState(
133+ input: RecoverAutomationRuntimeStateInput = {}
134+ ): Promise<RecoverAutomationRuntimeStateResult> {
135+ const now = normalizeNonNegativeInteger(input.now ?? Date.now(), 0, "now");
136+ const renewalRecoveryDelayMs = normalizePositiveInteger(
137+ input.renewalRecoveryDelayMs,
138+ DEFAULT_RENEWAL_RECOVERY_DELAY_MS
139+ );
140+ const recoveryNextAttemptAt = now + renewalRecoveryDelayMs;
141+ const lockedConversationIds = this.getRows<{ local_conversation_id: string }>(
142+ `
143+ SELECT local_conversation_id
144+ FROM local_conversations
145+ WHERE execution_state != 'idle';
146+ `
147+ ).map((row) => row.local_conversation_id);
148+ const runningJobIds = this.getRows<{ job_id: string }>(
149+ `
150+ SELECT job_id
151+ FROM renewal_jobs
152+ WHERE status = 'running';
153+ `
154+ ).map((row) => row.job_id);
155+
156+ if (lockedConversationIds.length === 0 && runningJobIds.length === 0) {
157+ return {
158+ recoveredExecutionStateCount: 0,
159+ recoveryNextAttemptAt,
160+ requeuedRenewalJobCount: 0
161+ };
162+ }
163+
164+ this.db.exec("BEGIN;");
165+
166+ try {
167+ this.db.prepare(RECOVER_RUNNING_RENEWAL_JOBS_SQL).run(
168+ now,
169+ recoveryNextAttemptAt,
170+ now
171+ );
172+ this.db.prepare(RECOVER_LOCAL_CONVERSATION_EXECUTION_SQL).run(
173+ now,
174+ now
175+ );
176+ this.db.exec("COMMIT;");
177+ } catch (error) {
178+ this.rollbackQuietly();
179+ throw error;
180+ }
181+
182+ for (const localConversationId of lockedConversationIds) {
183+ const record = await this.getLocalConversation(localConversationId);
184+
185+ if (record == null) {
186+ continue;
187+ }
188+
189+ this.enqueueSync(
190+ "local_conversations",
191+ record.localConversationId,
192+ localConversationSyncPayload(record),
193+ "update"
194+ );
195+ }
196+
197+ for (const jobId of runningJobIds) {
198+ const record = await this.getRenewalJob(jobId);
199+
200+ if (record == null) {
201+ continue;
202+ }
203+
204+ this.enqueueSync(
205+ "renewal_jobs",
206+ record.jobId,
207+ renewalJobSyncPayload(record),
208+ "update"
209+ );
210+ }
211+
212+ return {
213+ recoveredExecutionStateCount: lockedConversationIds.length,
214+ recoveryNextAttemptAt,
215+ requeuedRenewalJobCount: runningJobIds.length
216+ };
217+ }
218+
219 async upsertSession(input: UpsertSessionInput): Promise<SessionRecord> {
220 const record = buildSessionRecord(input, this.summaryLength);
221
222@@ -1866,6 +2032,16 @@ function buildUpdatedRenewalJobRecord(
223 };
224 }
225
226+function buildBrowserRequestPolicyStateRecord(
227+ input: UpsertBrowserRequestPolicyStateInput
228+): BrowserRequestPolicyStateRecord {
229+ return {
230+ stateKey: normalizeRequiredString(input.stateKey, "stateKey"),
231+ valueJson: normalizeRequiredString(input.valueJson, "valueJson"),
232+ updatedAt: normalizeNonNegativeInteger(input.updatedAt ?? Date.now(), 0, "updatedAt")
233+ };
234+}
235+
236 function buildWhereClause(conditions: Array<string | null>, separator: "AND"): string {
237 const filtered = conditions.filter((condition): condition is string => condition != null);
238 return filtered.length === 0 ? "" : `WHERE ${filtered.join(` ${separator} `)}`;
239@@ -2010,6 +2186,16 @@ function renewalJobParams(record: RenewalJobRecord): Array<number | string | nul
240 ];
241 }
242
243+function browserRequestPolicyStateParams(
244+ record: BrowserRequestPolicyStateRecord
245+): Array<number | string | null> {
246+ return [
247+ record.stateKey,
248+ record.valueJson,
249+ record.updatedAt
250+ ];
251+}
252+
253 function mapLocalConversationRow(row: LocalConversationRow): LocalConversationRecord {
254 return {
255 automationStatus: row.automation_status,
256@@ -2079,6 +2265,16 @@ function mapRenewalJobRow(row: RenewalJobRow): RenewalJobRecord {
257 };
258 }
259
260+function mapBrowserRequestPolicyStateRow(
261+ row: BrowserRequestPolicyStateRow
262+): BrowserRequestPolicyStateRecord {
263+ return {
264+ stateKey: row.state_key,
265+ valueJson: row.value_json,
266+ updatedAt: row.updated_at
267+ };
268+}
269+
270 function messageParams(record: MessageRecord): Array<number | string | null> {
271 return [
272 record.id,
273@@ -2443,6 +2639,16 @@ function renewalJobSyncPayload(record: RenewalJobRecord): Record<string, unknown
274 };
275 }
276
277+function browserRequestPolicyStateSyncPayload(
278+ record: BrowserRequestPolicyStateRecord
279+): Record<string, unknown> {
280+ return {
281+ state_key: record.stateKey,
282+ value_json: record.valueJson,
283+ updated_at: record.updatedAt
284+ };
285+}
286+
287 function buildStableHash(value: string): string {
288 let hash = 2_166_136_261;
289
+23,
-0
1@@ -165,6 +165,12 @@ export interface RenewalJobRecord {
2 updatedAt: number;
3 }
4
5+export interface BrowserRequestPolicyStateRecord {
6+ stateKey: string;
7+ valueJson: string;
8+ updatedAt: number;
9+}
10+
11 export interface InsertMessageInput {
12 id: string;
13 platform: string;
14@@ -287,6 +293,23 @@ export interface UpdateRenewalJobInput {
15 updatedAt?: number;
16 }
17
18+export interface UpsertBrowserRequestPolicyStateInput {
19+ stateKey: string;
20+ valueJson: string;
21+ updatedAt?: number;
22+}
23+
24+export interface RecoverAutomationRuntimeStateInput {
25+ now?: number;
26+ renewalRecoveryDelayMs?: number;
27+}
28+
29+export interface RecoverAutomationRuntimeStateResult {
30+ recoveredExecutionStateCount: number;
31+ recoveryNextAttemptAt: number;
32+ requeuedRenewalJobCount: number;
33+}
34+
35 export interface ListMessagesOptions {
36 conversationId?: string | null;
37 limit?: number;
+21,
-0
1@@ -83,6 +83,15 @@ CREATE TABLE IF NOT EXISTS local_conversations (
2 local_conversation_id TEXT PRIMARY KEY,
3 platform TEXT NOT NULL,
4 automation_status TEXT NOT NULL DEFAULT 'manual',
5+ last_non_paused_automation_status TEXT NOT NULL DEFAULT 'manual',
6+ pause_reason TEXT,
7+ last_error TEXT,
8+ execution_state TEXT NOT NULL DEFAULT 'idle',
9+ consecutive_failure_count INTEGER NOT NULL DEFAULT 0,
10+ repeated_message_count INTEGER NOT NULL DEFAULT 0,
11+ repeated_renewal_count INTEGER NOT NULL DEFAULT 0,
12+ last_message_fingerprint TEXT,
13+ last_renewal_fingerprint TEXT,
14 title TEXT,
15 summary TEXT,
16 last_message_id TEXT,
17@@ -99,6 +108,8 @@ CREATE INDEX IF NOT EXISTS idx_local_conversations_platform
18 ON local_conversations(platform, updated_at DESC);
19 CREATE INDEX IF NOT EXISTS idx_local_conversations_last_message
20 ON local_conversations(last_message_at DESC);
21+CREATE INDEX IF NOT EXISTS idx_local_conversations_last_message_id
22+ ON local_conversations(last_message_id);
23
24 -- conversation_links table (mirrors local artifact.db)
25 CREATE TABLE IF NOT EXISTS conversation_links (
26@@ -182,3 +193,13 @@ CREATE INDEX IF NOT EXISTS idx_renewal_jobs_status_due
27 ON renewal_jobs(status, next_attempt_at ASC, created_at ASC);
28 CREATE INDEX IF NOT EXISTS idx_renewal_jobs_local_conversation
29 ON renewal_jobs(local_conversation_id, status, created_at DESC);
30+
31+-- browser_request_policy_state table (mirrors local artifact.db)
32+CREATE TABLE IF NOT EXISTS browser_request_policy_state (
33+ state_key TEXT PRIMARY KEY,
34+ value_json TEXT NOT NULL,
35+ updated_at INTEGER NOT NULL
36+);
37+
38+CREATE INDEX IF NOT EXISTS idx_browser_request_policy_state_updated
39+ ON browser_request_policy_state(updated_at DESC);
+48,
-1
1@@ -308,7 +308,21 @@ describe("D1SyncWorker", () => {
2 local_conversation_id: "lc_001",
3 platform: "claude",
4 automation_status: "auto",
5+ last_non_paused_automation_status: "auto",
6+ pause_reason: "execution_failure",
7+ last_error: "boom",
8+ execution_state: "idle",
9+ consecutive_failure_count: 2,
10+ repeated_message_count: 1,
11+ repeated_renewal_count: 0,
12+ last_message_fingerprint: "msg-hash",
13+ last_renewal_fingerprint: "renew-hash",
14 title: "Renewal thread",
15+ summary: "Latest summary",
16+ last_message_id: "msg_001",
17+ last_message_at: 122,
18+ cooldown_until: 140,
19+ paused_at: 141,
20 created_at: 123,
21 updated_at: 124
22 }
23@@ -337,18 +351,43 @@ describe("D1SyncWorker", () => {
24 updated_at: 456
25 }
26 });
27+ queue.enqueueSyncRecord({
28+ tableName: "browser_request_policy_state",
29+ recordId: "global",
30+ operation: "update",
31+ payload: {
32+ state_key: "global",
33+ value_json: "{\"version\":1,\"platforms\":[],\"targets\":[]}",
34+ updated_at: 789
35+ }
36+ });
37
38 const records = queue.dequeuePendingSyncRecords(10);
39 await worker.syncRecord(records[0]);
40 await worker.syncRecord(records[1]);
41+ await worker.syncRecord(records[2]);
42
43- assert.equal(prepared.length, 2);
44+ assert.equal(prepared.length, 3);
45 assert.match(prepared[0].statement, /^INSERT INTO local_conversations \(/);
46 assert.deepEqual(prepared[0].params, [
47 "lc_001",
48 "claude",
49 "auto",
50+ "auto",
51+ "execution_failure",
52+ "boom",
53+ "idle",
54+ 2,
55+ 1,
56+ 0,
57+ "msg-hash",
58+ "renew-hash",
59 "Renewal thread",
60+ "Latest summary",
61+ "msg_001",
62+ 122,
63+ 140,
64+ 141,
65 123,
66 124
67 ]);
68@@ -372,6 +411,12 @@ describe("D1SyncWorker", () => {
69 123,
70 456
71 ]);
72+ assert.match(prepared[2].statement, /^INSERT INTO browser_request_policy_state \(/);
73+ assert.deepEqual(prepared[2].params, [
74+ "global",
75+ "{\"version\":1,\"platforms\":[],\"targets\":[]}",
76+ 789
77+ ]);
78 assert.equal(queue.dequeuePendingSyncRecords(10).length, 0);
79
80 db.close();
81@@ -685,8 +730,10 @@ describe("d1-setup.sql", () => {
82 assert.match(setupSql, /CREATE TABLE IF NOT EXISTS local_conversations/u);
83 assert.match(setupSql, /CREATE TABLE IF NOT EXISTS conversation_links/u);
84 assert.match(setupSql, /CREATE TABLE IF NOT EXISTS renewal_jobs/u);
85+ assert.match(setupSql, /CREATE TABLE IF NOT EXISTS browser_request_policy_state/u);
86 assert.match(setupSql, /idx_conversation_links_null_route/u);
87 assert.match(setupSql, /idx_conversation_links_null_page/u);
88 assert.match(setupSql, /idx_renewal_jobs_status_due/u);
89+ assert.match(setupSql, /idx_browser_request_policy_state_updated/u);
90 });
91 });
+14,
-0
1@@ -58,6 +58,15 @@ const SYNC_COLUMN_WHITELIST = {
2 "local_conversation_id",
3 "platform",
4 "automation_status",
5+ "last_non_paused_automation_status",
6+ "pause_reason",
7+ "last_error",
8+ "execution_state",
9+ "consecutive_failure_count",
10+ "repeated_message_count",
11+ "repeated_renewal_count",
12+ "last_message_fingerprint",
13+ "last_renewal_fingerprint",
14 "title",
15 "summary",
16 "last_message_id",
17@@ -104,6 +113,11 @@ const SYNC_COLUMN_WHITELIST = {
18 "finished_at",
19 "created_at",
20 "updated_at"
21+ ]),
22+ browser_request_policy_state: new Set([
23+ "state_key",
24+ "value_json",
25+ "updated_at"
26 ])
27 } as const;
28
+16,
-23
1@@ -50,6 +50,7 @@
2 - renewal dispatcher 已支持 inter-job jitter 和 retry jitter
3 - BAA normalize / parse 现在按 block 做错误隔离,单个坏 block 不再中断整批合法指令
4 - timed-jobs JSONL 日志现在已改为异步写入,减少 tick 周期内的同步 IO 阻塞
5+ - browser request 风控状态现在会持久化到 `artifact.db`,重启后会恢复限流/退避/熔断窗口,并回收遗留执行锁与 `running` renewal job
6
7 ## 当前已纠正的文档/代码不一致
8
9@@ -70,37 +71,30 @@
10
11 **当前下一波任务:**
12
13-1. `T-S062`:系统级暂停接入自动化主链
14-2. `T-S066`:风控状态持久化
15-3. `T-S069`:proxy_delivery 成功语义增强
16-4. `T-S068`:ChatGPT proxy send 冷启动降级保护
17-5. `T-S065`:policy 配置化
18-6. `T-S067`:Gemini 正式接入 raw relay 支持面
19-7. `OPT-004`:Claude final-message 更稳 fallback
20-8. `OPT-009`:renewal 模块重复工具函数抽取
21+1. `T-S069`:proxy_delivery 成功语义增强
22+2. `T-S068`:ChatGPT proxy send 冷启动降级保护
23+3. `T-S065`:policy 配置化
24+4. `T-S067`:Gemini 正式接入 raw relay 支持面
25+5. `OPT-004`:Claude final-message 更稳 fallback
26+6. `OPT-009`:renewal 模块重复工具函数抽取
27
28 并行需要持续关注:
29
30-- `T-S062`、`T-S066` 可以并行推进
31 - `T-S068`、`T-S069` 都会碰 delivery 路径,尽量不要并行修改同一批文件
32
33 **并行优化项:**
34
35-1. `T-S062`
36- 系统级暂停接入 BAA 与 timed-jobs 主链,补齐 automation gate
37-2. `T-S066`
38- 持久化限流、退避、熔断和 `pause_reason`,避免重启后风控状态丢失
39-3. `T-S069`
40+1. `T-S069`
41 为 proxy_delivery 补齐下游 HTTP 状态码回传,提升成功语义正确性
42-4. `T-S068`
43+2. `T-S068`
44 给 ChatGPT proxy send 补冷启动保护,避免插件重载后首批 delivery 直接失败
45-5. `T-S065`
46+3. `T-S065`
47 让 policy 白名单配置化,为后续 automation control 指令扩面铺路
48-6. `T-S067`
49+4. `T-S067`
50 把 Gemini 提升到正式 raw relay 支持面,减少 helper/proxy mix 带来的脆弱性
51-7. `OPT-004`
52+5. `OPT-004`
53 为 Claude final-message 增加更稳的 SSE fallback
54-8. `OPT-009`
55+6. `OPT-009`
56 renewal 模块重复工具函数抽取,减少重复逻辑
57
58 **已关闭的优化项:**
59@@ -138,8 +132,8 @@ Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续
60 - 已有本地对话/关联/续命任务、projector、dispatcher 和最小续命运维读接口
61 - 当前主线已无 open bug blocker
62 - `browser.chatgpt` / `browser.gemini` helper target 与 Gemini DOM delivery adapter 已在主线
63-- 当前主要以系统级暂停、重启后风控恢复和 delivery 可靠性增强为主
64-- 自动化仲裁、统一浮层控制已完成;系统级暂停继续由 `T-S062` 收口
65+- 当前主要以 delivery 可靠性增强、policy 配置化和 Gemini raw relay 收口为主
66+- 自动化仲裁、统一浮层控制、系统级暂停和重启后风控恢复都已完成
67
68 之前的浏览器主链继续保持:
69
70@@ -150,7 +144,6 @@ Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续
71
72 ## 当前仍需关注
73
74-- 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
75 - `Gemini` 当前仍不是 `/v1/browser/request` 的正式 raw relay 支持面;`@browser.gemini` 走 helper / proxy mix,仍需依赖最近观测到的真实请求上下文
76 - ChatGPT proxy send 仍依赖最近捕获的真实发送模板;如果 controller 刚重载且还没观察到真实发送,会退回同页 DOM fallback
77 - Claude 的 `organizationId` 当前仍依赖最近观测到的 org 上下文,不是完整的多页多 org 精确映射
78@@ -158,4 +151,4 @@ Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续
79 - ChatGPT root message / mapping 结构如果后续变化,final-message 提取启发式仍需跟进
80 - recent relay cache 是有限窗口;极老 replay 超出窗口后,仍会落回 conductor dedupe
81 - `status-api` 继续保留为显式 opt-in 兼容层,不是当前删除重点
82-- 以上几项风险现已分别拆成 `T-S062`、`T-S066`、`T-S067`、`T-S068`、`T-S069` 跟踪
83+- 以上几项风险现已分别拆成 `T-S067`、`T-S068`、`T-S069` 跟踪
+33,
-6
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- 当前状态:`待开始`
6+- 当前状态:`已完成`
7 - 规模预估:`M`
8 - 依赖任务:`T-S060`
9 - 建议执行者:`Codex`
10@@ -80,22 +80,49 @@
11
12 ### 开始执行
13
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-04-01 16:07:01 CST`
18 - 状态变更:`待开始` → `进行中`
19
20 ### 完成摘要
21
22-- 完成时间:
23+- 完成时间:`2026-04-01 16:52:07 CST`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+ - `apps/conductor-daemon/src/browser-request-policy.ts`
27+ - `apps/conductor-daemon/src/index.ts`
28+ - `apps/conductor-daemon/src/index.test.js`
29+ - `packages/artifact-db/src/types.ts`
30+ - `packages/artifact-db/src/index.ts`
31+ - `packages/artifact-db/src/schema.ts`
32+ - `packages/artifact-db/src/store.ts`
33+ - `packages/artifact-db/src/index.test.js`
34+ - `packages/d1-client/src/sync-worker.ts`
35+ - `packages/d1-client/src/d1-setup.sql`
36+ - `packages/d1-client/src/index.test.js`
37+ - `docs/api/control-interfaces.md`
38+ - `docs/api/business-interfaces.md`
39+ - `tasks/T-S066.md`
40+ - `tasks/TASK_OVERVIEW.md`
41+ - `plans/STATUS_SUMMARY.md`
42 - 核心实现思路:
43+ - 为 browser request 风控状态新增 `artifact.db` 持久化快照,保存平台级 dispatch window 和 target 级退避/熔断状态
44+ - `BrowserRequestPolicyController` 改为启动时恢复、变更后异步 flush,避免热路径同步写阻塞
45+ - `ConductorRuntime.start()` 增加恢复流程,重启时把遗留 `execution_state` 清成 `idle`,把 `running` renewal job 重新排回 `pending`
46+ - 补 `artifact-db` / `d1-client` / `conductor-daemon` 测试,覆盖重启恢复和 D1 同步合同
47 - 跑了哪些测试:
48+ - `pnpm install`
49+ - `pnpm -C packages/artifact-db test`
50+ - `pnpm -C packages/d1-client test`
51+ - `pnpm -C apps/conductor-daemon test`
52+ - `pnpm build`
53
54 ### 执行过程中遇到的问题
55
56--
57+- 新 worktree 初始没有 `node_modules`,先执行了 `pnpm install`
58+- 运行中的 renewal job 默认会带当前真实时间的 `next_attempt_at`,测试里显式改成 `null` 后才能稳定覆盖“重启回排”场景
59
60 ### 剩余风险
61
62--
63+- 持久化的是限流/退避/熔断窗口与恢复所需状态,不会恢复进程内 `in_flight` lease;如果 crash 发生在请求已发出但结果未落库的瞬间,只能通过重启回排延迟来降低重复发送风险
64+- 如果远端 Cloudflare D1 仍停留在旧 schema,需要先应用更新后的 `d1-setup.sql` 或对应迁移,才能完整镜像新增列和 `browser_request_policy_state`
+4,
-8
1@@ -48,6 +48,7 @@
2 - BAA normalize / parse 现在按 block 做错误隔离,单个坏 block 不再中断整批合法指令
3 - timed-jobs JSONL 日志现在已改为异步写入,减少 tick 周期内的同步 IO 阻塞
4 - 系统级 automation pause 已落地:`system paused` 会同时阻断 live BAA 指令和 timed-jobs 主链,且不会覆盖各对话原有 `pause_reason`
5+ - browser request 风控状态现在会持久化到 `artifact.db`,重启后会恢复限流/退避/熔断窗口,并清理遗留执行锁与 `running` renewal job
6
7 ## 当前已确认的不一致
8
9@@ -82,12 +83,12 @@
10 | [`T-S062`](./T-S062.md) | 系统级暂停接入自动化主链 | M | T-S060 | Codex | 已完成 |
11 | [`T-S063`](./T-S063.md) | normalize / parse 错误隔离 | S | 无 | Codex | 已完成 |
12 | [`T-S064`](./T-S064.md) | timed-jobs 异步日志写入 | S | 无 | Codex | 已完成 |
13+| [`T-S066`](./T-S066.md) | 风控状态持久化 | M | T-S060 | Codex | 已完成 |
14
15 ### 当前下一波任务
16
17 | 项目 | 标题 | 类型 | 状态 | 说明 |
18 |---|---|---|---|---|
19-| [`T-S066`](./T-S066.md) | 风控状态持久化 | task | 待开始 | 让限流、退避、熔断和 `pause_reason` 在重启后恢复 |
20 | [`T-S069`](./T-S069.md) | proxy_delivery 成功语义增强 | task | 待开始 | 至少补齐下游 HTTP 状态码回传,避免“已派发”直接等于成功 |
21 | [`T-S068`](./T-S068.md) | ChatGPT proxy send 冷启动降级保护 | task | 待开始 | 减少插件重载后首批 delivery 直接失败或退回 DOM fallback |
22 | [`T-S065`](./T-S065.md) | policy 配置化 | task | 待开始 | 为自动化控制指令和后续扩面提供策略入口 |
23@@ -141,10 +142,6 @@
24
25 ### P1(并行优化)
26
27-- [`T-S066`](./T-S066.md)
28-
29-### P2(次级优化)
30-
31 - [`T-S069`](./T-S069.md)
32 - [`T-S068`](./T-S068.md)
33 - [`T-S065`](./T-S065.md)
34@@ -185,10 +182,9 @@
35
36 ## 当前主线判断
37
38-Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060`、`T-S061`、`T-S062`、`T-S063`、`T-S064` 已经落地。当前主线已经没有 open bug blocker,下一步是:
39+Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060`、`T-S061`、`T-S062`、`T-S063`、`T-S064`、`T-S066` 已经落地。当前主线已经没有 open bug blocker,下一步是:
40
41-- 并行推进 `T-S066`
42-- 然后收口 `T-S069`
43+- 先收口 `T-S069`
44 - 再做 `T-S068`、`T-S065`
45 - 最后再推进 `T-S067`
46 - `OPT-004`、`OPT-009` 继续保留为 open opt