baa-conductor

git clone 

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
M apps/conductor-daemon/src/browser-request-policy.ts
+398, -0
  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 }
M apps/conductor-daemon/src/index.test.js
+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-"));
M apps/conductor-daemon/src/index.ts
+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 {
M docs/api/business-interfaces.md
+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`
M docs/api/control-interfaces.md
+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`
M packages/artifact-db/src/index.test.js
+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");
M packages/artifact-db/src/index.ts
+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
M packages/artifact-db/src/schema.ts
+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 `;
M packages/artifact-db/src/store.ts
+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 
M packages/artifact-db/src/types.ts
+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;
M packages/d1-client/src/d1-setup.sql
+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);
M packages/d1-client/src/index.test.js
+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 });
M packages/d1-client/src/sync-worker.ts
+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 
M plans/STATUS_SUMMARY.md
+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` 跟踪
M tasks/T-S066.md
+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`
M tasks/TASK_OVERVIEW.md
+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