baa-conductor

git clone 

commit
c9e1441
parent
37f4a25
author
im_wower
date
2026-03-22 16:41:48 +0800 CST
feat(firefox): auto-retry control api sync
5 files changed,  +348, -53
M docs/firefox/README.md
+13, -3
 1@@ -57,6 +57,8 @@
 2 - 插件负责显示状态和发起人工控制动作,不负责创建、分配、恢复 task。
 3 - `pause`、`resume`、`drain` 都是全局动作,不是单标签页动作。
 4 - Firefox 插件默认模式不依赖 websocket;如果保留兼容能力,也只能作为手动启用的可选通道。
 5+- Firefox 启动时由 `background.js` 确保 controller 页存在;controller 启动后立即同步 Control API。
 6+- Control API 临时失败时,插件必须持续自动重试;服务恢复后自动回到正常同步周期。
 7 - 插件里的“跟踪标签页”只统计当前真实打开且仍匹配平台 host 的页面,不把历史 storage 残留当成当前状态。
 8 - 插件里的“凭证”只统计绑定到当前 tracked tab、最近重新捕获且仍通过平台登录规则校验的快照;启动时不自动补开全部平台页。
 9 
10@@ -81,6 +83,7 @@
11 
12 - `CONTROL_API_AUTH_REQUIRED=false`
13 - Firefox 插件默认直接请求 `https://control-api.makefile.so`
14+- 如果用户没有手动保存配置,插件会继续默认使用 `https://control-api.makefile.so`
15 - Firefox 插件默认不配置 websocket,也不会主动连接 `ws://127.0.0.1:9800`
16 - 当前不要求本地 `baa-server`
17 - 当前不要求在插件里填写 token
18@@ -144,8 +147,12 @@ Firefox 插件至少要读取这些字段:
19 
20 插件行为:
21 
22-- popup 或侧边栏打开时立即请求一次。
23-- 建议每 `5` 到 `10` 秒轮询一次;面板关闭后停止轮询。
24+- Firefox 启动时,background 会自动确保 `controller.html` 标签页存在。
25+- `controller.html` 启动后立即请求一次 `GET /v1/system/state`。
26+- 当前实现成功后按 `15` 秒周期继续同步。
27+- 当前实现失败后按 `1` 秒、`3` 秒、`5` 秒快速重试,再切到每 `30` 秒一次的慢速重试。
28+- 服务恢复后自动回到 `15` 秒正常同步周期,不需要手动刷新页面或重新打开 controller。
29+- `刷新控制面` 按钮仍然保留,但只是额外的手动触发入口。
30 - 每次成功执行 `pause`、`resume`、`drain` 后,优先使用写接口返回的新状态更新 UI;如果后端暂未回传完整状态,则立即补一次 `GET /v1/system/state`。
31 
32 ## 4.1 能力发现和辅助只读接口
33@@ -282,11 +289,14 @@ Firefox 插件至少要读取这些字段:
34 
35 推荐展示字段:
36 
37+- Control API 连接状态
38 - 当前 `mode`
39 - 当前 leader host
40 - `active_runs`
41 - `queued_tasks`
42-- 可选 WS 状态;未配置时展示 `未启用`,不要展示成 `WS 未连接`
43+- 最近一次成功同步时间
44+- 自动重试中的失败信息和下一次重试时间
45+- 可选 WS 状态;未配置时展示 `未启用`,不要展示成主连接故障
46 
47 ## 7. `control` 与 `dispatch` 边界
48 
M plugins/baa-firefox/background.js
+54, -3
 1@@ -7,6 +7,12 @@ const MODE_BADGES = {
 2   draining: { text: "排空", color: "#8b5e34" },
 3   unknown: { text: "--", color: "#7f7a70" }
 4 };
 5+const CONNECTION_BADGES = {
 6+  connecting: { text: "连中", color: "#8b5e34" },
 7+  connected: MODE_BADGES.unknown,
 8+  retrying: { text: "重试", color: "#a6512f" },
 9+  disconnected: { text: "断开", color: "#7f7a70" }
10+};
11 
12 async function setControllerTabId(tabId) {
13   await browser.storage.local.set({ [STORAGE_KEY]: tabId });
14@@ -64,16 +70,60 @@ function normalizeMode(value) {
15   return "unknown";
16 }
17 
18+function normalizeControlConnection(value) {
19+  const lower = String(value || "").trim().toLowerCase();
20+  if (lower === "connecting" || lower === "connected" || lower === "retrying" || lower === "disconnected") {
21+    return lower;
22+  }
23+  return "disconnected";
24+}
25+
26+function formatModeLabel(mode) {
27+  switch (normalizeMode(mode)) {
28+    case "running":
29+      return "运行中";
30+    case "paused":
31+      return "已暂停";
32+    case "draining":
33+      return "排空中";
34+    default:
35+      return "未知";
36+  }
37+}
38+
39+function formatControlConnectionLabel(connection) {
40+  switch (normalizeControlConnection(connection)) {
41+    case "connecting":
42+      return "连接中";
43+    case "connected":
44+      return "已连接";
45+    case "retrying":
46+      return "正在重试";
47+    default:
48+      return "已断开";
49+  }
50+}
51+
52+function formatSyncTime(timestamp) {
53+  if (!Number.isFinite(timestamp) || timestamp <= 0) return "未同步";
54+  return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
55+}
56+
57 async function updateActionBadge(snapshot) {
58   const mode = normalizeMode(snapshot?.mode);
59-  const badge = snapshot?.error ? { text: "ERR", color: "#a6512f" } : MODE_BADGES[mode];
60-  const modeLabel = mode === "running" ? "运行中" : mode === "paused" ? "已暂停" : mode === "draining" ? "排空中" : "未知";
61+  const connection = normalizeControlConnection(snapshot?.controlConnection);
62+  const badge = connection === "connected" ? MODE_BADGES[mode] : CONNECTION_BADGES[connection];
63   const leader = snapshot?.leader ? `\n主控: ${snapshot.leader}` : "";
64+  const lastSuccess = snapshot?.lastSuccessAt ? `\n最近成功: ${formatSyncTime(snapshot.lastSuccessAt)}` : "";
65+  const nextRetry = connection === "retrying" && snapshot?.nextRetryAt
66+    ? `\n下次重试: ${formatSyncTime(snapshot.nextRetryAt)}`
67+    : "";
68+  const error = snapshot?.error && connection !== "connected" ? `\n错误: ${snapshot.error}` : "";
69 
70   await browser.action.setBadgeText({ text: badge.text });
71   await browser.action.setBadgeBackgroundColor({ color: badge.color });
72   await browser.action.setTitle({
73-    title: `BAA Firefox 管理页\n模式: ${modeLabel}${leader}`
74+    title: `BAA Firefox 管理页\n连接: ${formatControlConnectionLabel(connection)}\n自动化: ${formatModeLabel(mode)}${leader}${lastSuccess}${nextRetry}${error}`
75   });
76 }
77 
78@@ -127,4 +177,5 @@ browser.tabs.onRemoved.addListener((tabId) => {
79   }).catch(() => {});
80 });
81 
82+ensureControllerTab({ activate: false }).catch(() => {});
83 refreshActionBadgeFromStorage().catch(() => {});
M plugins/baa-firefox/controller.html
+2, -2
 1@@ -29,7 +29,7 @@
 2       <label for="control-base-url">控制 API</label>
 3       <input id="control-base-url" type="text" spellcheck="false" placeholder="https://control-api.makefile.so">
 4       <button id="save-control-btn" type="button">保存控制面配置</button>
 5-      <button id="refresh-control-btn" type="button">刷新状态</button>
 6+      <button id="refresh-control-btn" type="button">刷新控制面</button>
 7       <button id="pause-btn" type="button">暂停</button>
 8       <button id="resume-btn" type="button">恢复</button>
 9       <button id="drain-btn" type="button">排空</button>
10@@ -37,7 +37,7 @@
11 
12     <section class="grid">
13       <article class="card">
14-        <p class="label">自动化模式</p>
15+        <p class="label">控制面连接</p>
16         <p id="control-mode" class="value off">未知</p>
17         <p id="control-meta" class="meta">未同步</p>
18       </article>
M plugins/baa-firefox/controller.js
+255, -40
  1@@ -34,6 +34,9 @@ const LOG_LIMIT = 160;
  2 const PROXY_MESSAGE_RETRY = 10;
  3 const PROXY_MESSAGE_RETRY_DELAY = 400;
  4 const CONTROL_REFRESH_INTERVAL = 15_000;
  5+const CONTROL_RETRY_DELAYS = [1_000, 3_000, 5_000];
  6+const CONTROL_RETRY_SLOW_INTERVAL = 30_000;
  7+const CONTROL_RETRY_LOG_INTERVAL = 60_000;
  8 const TRACKED_TAB_REFRESH_DELAY = 150;
  9 const CONTROL_STATUS_BODY_LIMIT = 12_000;
 10 const CHATGPT_SESSION_COOKIE_PATTERNS = [
 11@@ -157,6 +160,9 @@ const state = {
 12   wsConnected: false,
 13   reconnectTimer: null,
 14   controlRefreshTimer: null,
 15+  controlRefreshInFlight: null,
 16+  lastControlFailureLogAt: 0,
 17+  lastControlFailureKey: "",
 18   trackedTabRefreshTimer: null,
 19   trackedTabRefreshRunning: false,
 20   trackedTabRefreshQueued: false,
 21@@ -247,6 +253,11 @@ function createDefaultControlState(overrides = {}) {
 22     message: null,
 23     statusCode: null,
 24     syncedAt: 0,
 25+    controlConnection: "disconnected",
 26+    retryCount: 0,
 27+    lastSuccessAt: 0,
 28+    lastFailureAt: 0,
 29+    nextRetryAt: 0,
 30     source: "bootstrap",
 31     raw: null,
 32     ...overrides
 33@@ -464,14 +475,80 @@ function formatSyncTime(timestamp) {
 34   return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
 35 }
 36 
 37+function formatRetryDelay(targetTime) {
 38+  if (!Number.isFinite(targetTime) || targetTime <= 0) return "";
 39+  const remaining = Math.max(0, targetTime - Date.now());
 40+  if (remaining < 1_000) return "不到 1 秒";
 41+  if (remaining < 60_000) return `${Math.ceil(remaining / 1_000)} 秒`;
 42+  return `${Math.ceil(remaining / 60_000)} 分钟`;
 43+}
 44+
 45 function isWsEnabled() {
 46   return !!state.wsUrl;
 47 }
 48 
 49-function controlModeClass(snapshot) {
 50-  if (!snapshot || snapshot.mode === "unknown") return "off";
 51-  if (snapshot.error) return "warn";
 52-  return snapshot.mode === "running" ? "on" : "warn";
 53+function normalizeControlConnection(value) {
 54+  switch (String(value || "").trim().toLowerCase()) {
 55+    case "connecting":
 56+    case "connected":
 57+    case "retrying":
 58+    case "disconnected":
 59+      return String(value || "").trim().toLowerCase();
 60+    default:
 61+      return "disconnected";
 62+  }
 63+}
 64+
 65+function formatControlConnectionLabel(snapshot) {
 66+  switch (normalizeControlConnection(snapshot?.controlConnection)) {
 67+    case "connecting":
 68+      return "连接中";
 69+    case "connected":
 70+      return "已连接";
 71+    case "retrying":
 72+      return "正在重试";
 73+    default:
 74+      return "已断开";
 75+  }
 76+}
 77+
 78+function controlConnectionClass(snapshot) {
 79+  switch (normalizeControlConnection(snapshot?.controlConnection)) {
 80+    case "connected":
 81+      return "on";
 82+    case "connecting":
 83+    case "retrying":
 84+      return "warn";
 85+    default:
 86+      return "off";
 87+  }
 88+}
 89+
 90+function formatControlMeta(snapshot) {
 91+  const parts = [`自动化模式: ${formatModeLabel(snapshot.mode)}`];
 92+  const connection = normalizeControlConnection(snapshot.controlConnection);
 93+
 94+  if (snapshot.lastSuccessAt > 0) {
 95+    parts.push(`最近成功: ${formatSyncTime(snapshot.lastSuccessAt)}`);
 96+  } else if (connection === "connecting") {
 97+    parts.push("浏览器启动后自动连接中");
 98+  }
 99+
100+  if (connection === "retrying") {
101+    parts.push("最近同步失败");
102+    if (snapshot.lastFailureAt > 0) {
103+      parts.push(`失败时间: ${formatSyncTime(snapshot.lastFailureAt)}`);
104+    }
105+    if (snapshot.nextRetryAt > 0) {
106+      parts.push(`下次重试: ${formatRetryDelay(snapshot.nextRetryAt)}`);
107+    }
108+  }
109+
110+  if (snapshot.error && connection !== "connected") {
111+    parts.push(`错误: ${snapshot.error}`);
112+  }
113+
114+  return parts.join(" · ");
115 }
116 
117 function normalizeControlStatePayload(payload, meta = {}) {
118@@ -1122,6 +1199,8 @@ function renderControlSnapshot() {
119 
120   return JSON.stringify({
121     ok: snapshot.ok,
122+    controlConnection: snapshot.controlConnection,
123+    retryCount: snapshot.retryCount,
124     mode: snapshot.mode,
125     leader: snapshot.leader,
126     leaseHolder: snapshot.leaseHolder,
127@@ -1129,6 +1208,9 @@ function renderControlSnapshot() {
128     activeRuns: snapshot.activeRuns,
129     statusCode: snapshot.statusCode,
130     syncedAt: snapshot.syncedAt ? new Date(snapshot.syncedAt).toISOString() : null,
131+    lastSuccessAt: snapshot.lastSuccessAt ? new Date(snapshot.lastSuccessAt).toISOString() : null,
132+    lastFailureAt: snapshot.lastFailureAt ? new Date(snapshot.lastFailureAt).toISOString() : null,
133+    nextRetryAt: snapshot.nextRetryAt ? new Date(snapshot.nextRetryAt).toISOString() : null,
134     source: snapshot.source,
135     error: snapshot.error,
136     message: snapshot.message,
137@@ -1167,9 +1249,9 @@ function render() {
138   ui.clientId.textContent = !wsEnabled
139     ? "可选能力;当前默认仅使用 Control API"
140     : `客户端: ${state.clientId || "-"}${state.wsConnected ? "" : " · 等待连接"}`;
141-  ui.controlMode.textContent = formatModeLabel(controlSnapshot.mode);
142-  ui.controlMode.className = `value ${controlModeClass(controlSnapshot)}`;
143-  ui.controlMeta.textContent = `${formatSyncTime(controlSnapshot.syncedAt)}${controlSnapshot.error ? ` · ${controlSnapshot.error}` : ""}`;
144+  ui.controlMode.textContent = formatControlConnectionLabel(controlSnapshot);
145+  ui.controlMode.className = `value ${controlConnectionClass(controlSnapshot)}`;
146+  ui.controlMeta.textContent = formatControlMeta(controlSnapshot);
147   ui.leaderValue.textContent = controlSnapshot.leader || "-";
148   ui.leaderValue.className = `value ${controlSnapshot.leader ? "on" : "off"}`;
149   ui.leaderMeta.textContent = `租约: ${controlSnapshot.leaseHolder || "-"}`;
150@@ -1178,7 +1260,7 @@ function render() {
151   ui.queueMeta.textContent = "排队任务";
152   ui.runsValue.textContent = controlSnapshot.activeRuns == null ? "-" : String(controlSnapshot.activeRuns);
153   ui.runsValue.className = `value ${controlSnapshot.activeRuns > 0 ? "warn" : controlSnapshot.activeRuns === 0 ? "on" : "off"}`;
154-  ui.runsMeta.textContent = controlSnapshot.message || "控制面";
155+  ui.runsMeta.textContent = `自动化模式: ${formatModeLabel(controlSnapshot.mode)}`;
156   ui.platformsView.textContent = renderPlatformStatus();
157   ui.controlView.textContent = renderControlSnapshot();
158   ui.headersView.textContent = renderHeaderSnapshot();
159@@ -1245,35 +1327,146 @@ async function requestControlPlane(path, options = {}) {
160   };
161 }
162 
163+function getControlRetryDelay(retryCount) {
164+  const index = Math.max(0, Number(retryCount) - 1);
165+  return CONTROL_RETRY_DELAYS[index] || CONTROL_RETRY_SLOW_INTERVAL;
166+}
167+
168+function resetControlFailureLog() {
169+  state.lastControlFailureKey = "";
170+  state.lastControlFailureLogAt = 0;
171+}
172+
173+function createControlSuccessState(payload, meta = {}, previousSnapshot = null) {
174+  const normalized = normalizeControlStatePayload(payload, meta);
175+  const previous = cloneControlState(previousSnapshot || state.controlState);
176+
177+  return createDefaultControlState({
178+    ...previous,
179+    ...normalized,
180+    ok: true,
181+    error: null,
182+    controlConnection: "connected",
183+    retryCount: 0,
184+    lastSuccessAt: normalized.syncedAt,
185+    lastFailureAt: 0,
186+    nextRetryAt: 0
187+  });
188+}
189+
190+function createControlFailureState(error, meta = {}, previousSnapshot = null) {
191+  const previous = cloneControlState(previousSnapshot || state.controlState);
192+  const normalized = normalizeControlStatePayload(error.payload || error.message, {
193+    ok: false,
194+    statusCode: Number.isFinite(error.statusCode) ? error.statusCode : null,
195+    source: meta.source || "http",
196+    error: error.message
197+  });
198+  const next = createDefaultControlState({
199+    ...previous,
200+    ok: false,
201+    error: error.message,
202+    message: previous.lastSuccessAt > 0
203+      ? "最近同步失败,正在自动重试"
204+      : "控制面暂不可用,正在自动重试",
205+    statusCode: normalized.statusCode,
206+    syncedAt: normalized.syncedAt,
207+    controlConnection: "retrying",
208+    retryCount: meta.retryCount || Math.max(1, previous.retryCount || 0),
209+    lastFailureAt: normalized.syncedAt,
210+    nextRetryAt: meta.nextRetryAt || 0,
211+    source: normalized.source,
212+    raw: normalized.raw
213+  });
214+
215+  if (normalized.mode !== "unknown") next.mode = normalized.mode;
216+  if (normalized.leader) next.leader = normalized.leader;
217+  if (normalized.leaseHolder) next.leaseHolder = normalized.leaseHolder;
218+  if (normalized.queueDepth != null) next.queueDepth = normalized.queueDepth;
219+  if (normalized.activeRuns != null) next.activeRuns = normalized.activeRuns;
220+
221+  return next;
222+}
223+
224+function logControlFailure(snapshot, options = {}) {
225+  const key = `${snapshot.statusCode || "-"}:${snapshot.error || "unknown"}`;
226+  const now = Date.now();
227+  const shouldLog = !options.silent
228+    || snapshot.retryCount <= 1
229+    || key !== state.lastControlFailureKey
230+    || now - state.lastControlFailureLogAt >= CONTROL_RETRY_LOG_INTERVAL;
231+
232+  if (!shouldLog) return;
233+
234+  state.lastControlFailureKey = key;
235+  state.lastControlFailureLogAt = now;
236+
237+  const retryDelay = formatRetryDelay(snapshot.nextRetryAt);
238+  addLog(
239+    "warn",
240+    `控制面同步失败:${snapshot.error}${retryDelay ? `;${retryDelay}后继续重试` : ""}`,
241+    false
242+  );
243+}
244+
245 async function refreshControlPlaneState(options = {}) {
246-  try {
247-    const response = await requestControlPlane("/v1/system/state");
248-    const snapshot = normalizeControlStatePayload(response.payload, {
249-      ok: true,
250-      statusCode: response.statusCode,
251-      source: options.source || "http"
252-    });
253-    await setControlState(snapshot);
254+  if (state.controlRefreshInFlight) {
255+    return state.controlRefreshInFlight;
256+  }
257 
258-    if (!options.silent) {
259-      addLog("info", `控制面状态已同步:模式=${formatModeLabel(snapshot.mode)},主控=${snapshot.leader || "-"}`);
260-    }
261+  const task = (async () => {
262+    const source = options.source || "http";
263+    const previousSnapshot = cloneControlState(state.controlState);
264+    const previousConnection = normalizeControlConnection(previousSnapshot.controlConnection);
265 
266-    return snapshot;
267-  } catch (error) {
268-    const snapshot = normalizeControlStatePayload(error.payload || error.message, {
269-      ok: false,
270-      statusCode: Number.isFinite(error.statusCode) ? error.statusCode : null,
271-      source: options.source || "http",
272-      error: error.message
273-    });
274-    await setControlState(snapshot);
275+    try {
276+      const response = await requestControlPlane("/v1/system/state");
277+      const snapshot = createControlSuccessState(response.payload, {
278+        ok: true,
279+        statusCode: response.statusCode,
280+        source
281+      }, previousSnapshot);
282+      await setControlState(snapshot);
283+      resetControlFailureLog();
284+      restartControlPlaneRefreshTimer(CONTROL_REFRESH_INTERVAL, {
285+        reason: "poll"
286+      });
287 
288-    if (!options.silent) {
289-      addLog("error", `控制面状态同步失败:${error.message}`);
290+      if (!options.silent) {
291+        addLog("info", `控制面状态已同步:模式=${formatModeLabel(snapshot.mode)},主控=${snapshot.leader || "-"}`, false);
292+      } else if (previousConnection !== "connected") {
293+        addLog("info", `控制面已恢复:模式=${formatModeLabel(snapshot.mode)},主控=${snapshot.leader || "-"}`, false);
294+      }
295+
296+      return snapshot;
297+    } catch (error) {
298+      const retryCount = previousConnection === "retrying"
299+        ? (Number(previousSnapshot.retryCount) || 0) + 1
300+        : 1;
301+      const retryDelay = getControlRetryDelay(retryCount);
302+      const nextRetryAt = Date.now() + retryDelay;
303+      const snapshot = createControlFailureState(error, {
304+        source,
305+        retryCount,
306+        nextRetryAt
307+      }, previousSnapshot);
308+      await setControlState(snapshot);
309+      restartControlPlaneRefreshTimer(retryDelay, {
310+        reason: "retry"
311+      });
312+      logControlFailure(snapshot, options);
313+      throw error;
314     }
315+  })();
316 
317-    throw error;
318+  state.controlRefreshInFlight = task;
319+
320+  try {
321+    return await task;
322+  } finally {
323+    if (state.controlRefreshInFlight === task) {
324+      state.controlRefreshInFlight = null;
325+    }
326   }
327 }
328 
329@@ -1305,12 +1498,34 @@ async function runControlPlaneAction(action, options = {}) {
330   };
331 }
332 
333-function restartControlPlaneRefreshTimer() {
334-  clearInterval(state.controlRefreshTimer);
335-  state.controlRefreshTimer = setInterval(() => {
336-    refreshTrackedTabsFromBrowser("poll").catch(() => {});
337-    refreshControlPlaneState({ source: "poll", silent: true }).catch(() => {});
338-  }, CONTROL_REFRESH_INTERVAL);
339+async function runScheduledControlPlaneRefresh(reason = "poll") {
340+  await refreshTrackedTabsFromBrowser(reason).catch(() => {});
341+  await refreshControlPlaneState({
342+    source: reason,
343+    silent: true
344+  }).catch(() => {});
345+}
346+
347+function restartControlPlaneRefreshTimer(delay = CONTROL_REFRESH_INTERVAL, options = {}) {
348+  clearTimeout(state.controlRefreshTimer);
349+  state.controlRefreshTimer = setTimeout(() => {
350+    runScheduledControlPlaneRefresh(options.reason || "poll").catch(() => {});
351+  }, Math.max(0, Number(delay) || 0));
352+}
353+
354+async function prepareStartupControlState() {
355+  const previous = cloneControlState(state.controlState);
356+  await setControlState(createDefaultControlState({
357+    ...previous,
358+    ok: previous.lastSuccessAt > 0,
359+    controlConnection: "connecting",
360+    retryCount: 0,
361+    nextRetryAt: 0,
362+    lastFailureAt: 0,
363+    error: null,
364+    message: "浏览器启动后自动连接中",
365+    source: "startup"
366+  }));
367 }
368 
369 function wsSend(payload) {
370@@ -2129,7 +2344,7 @@ function bindUi() {
371   });
372 
373   qs("save-control-btn").addEventListener("click", () => {
374-    state.controlBaseUrl = trimTrailingSlash(ui.controlBaseUrl.value) || DEFAULT_CONTROL_BASE_URL;
375+    state.controlBaseUrl = normalizeSavedControlBaseUrl(ui.controlBaseUrl.value);
376     persistState().catch(() => {});
377     addLog("info", `已保存控制 API:${state.controlBaseUrl}`, false);
378     render();
379@@ -2225,12 +2440,12 @@ async function init() {
380   } else {
381     addLog("info", "当前默认模式只使用 Control API;可选 WS 保持未启用", false);
382   }
383-  restartControlPlaneRefreshTimer();
384+  await prepareStartupControlState();
385   refreshControlPlaneState({ source: "startup", silent: true }).catch(() => {});
386 }
387 
388 window.addEventListener("beforeunload", () => {
389-  clearInterval(state.controlRefreshTimer);
390+  clearTimeout(state.controlRefreshTimer);
391   clearTimeout(state.trackedTabRefreshTimer);
392   closeWsConnection();
393 });
M plugins/baa-firefox/docs/conductor-control.md
+24, -5
 1@@ -1,16 +1,18 @@
 2 # Firefox Conductor Control
 3 
 4-`baa-firefox` 现在包含一条最小可用的 conductor control-plane 接线。
 5+`baa-firefox` 现在默认通过 Control API 自动连接 conductor control-plane,并在 Firefox 启动后自动恢复同步。
 6 
 7 ## 范围
 8 
 9 - `controller.html` / `controller.js`
10   - 配置 Control API base URL
11-  - 读取 `GET /v1/system/state`
12+  - 启动后立即读取 `GET /v1/system/state`
13+  - 失败时自动退避重试,成功后恢复常规轮询
14   - 调用 `POST /v1/system/pause`
15   - 调用 `POST /v1/system/resume`
16   - 调用 `POST /v1/system/drain`
17 - `background.js`
18+  - Firefox 启动时确保 `controller.html` 存在
19   - 根据最新 control snapshot 更新扩展 badge
20 - `content-script.js`
21   - 在 Claude 页面右下角提供最小浮层入口
22@@ -28,6 +30,7 @@
23 
24 当前临时单节点模式默认不开启 Control API 鉴权,因此插件不会再要求填写 bearer token。
25 当前默认模式也不再要求本地 `ws://127.0.0.1:9800` 或 `ws://localhost:9800`。
26+如果 storage 中没有显式配置,插件仍然会默认回落到 `https://control-api.makefile.so`。
27 
28 状态快照会持久化到 `browser.storage.local` 的 `baaFirefox.controlState`,供:
29 
30@@ -44,15 +47,31 @@
31 - `lease_holder`
32 - `queue_depth` / `queued_tasks`
33 - `active_runs`
34+- `controlConnection`
35+- `retryCount`
36+- `lastSuccessAt`
37+- `lastFailureAt`
38+- `nextRetryAt`
39 
40 如果服务端实际字段名略有不同,`controller.js` 已做多路径兼容解析。
41 
42+## 默认连接行为
43+
44+- Firefox 启动时,`background.js` 会确保 `controller.html` 标签页存在。
45+- `controller.html` 启动后会立刻请求 `GET /v1/system/state`,不需要用户先点“刷新控制面”。
46+- 正常同步成功后,插件按 `15` 秒周期继续拉取 Control API。
47+- 如果 Control API 临时失败、服务端重启或网络抖动,插件会按 `1` 秒、`3` 秒、`5` 秒快速重试,再进入 `30` 秒慢速重试,不会停在一次性报错状态。
48+- 服务恢复后,插件会自动回到正常已连接状态,不需要手动刷新页面或重新打开 controller。
49+
50 ## 可见入口
51 
52 - 扩展工具栏 badge
53-  - `RUN` / `PAU` / `DRN` / `ERR`
54+  - 已连接时显示自动化模式
55+  - 自动重试时显示重试状态
56 - `controller.html`
57-  - 优先展示 control-plane 卡片、原始快照和动作按钮
58+  - 第一张卡片主状态表示 Control API 连接状态
59+  - 会显示最近成功时间、最近失败和下一次重试
60+  - 保留“刷新控制面”按钮作为手动触发入口
61   - 如果未配置 WS,则显示 `未启用`,不再把它当成主故障
62 - Claude 页面
63   - 右下角 `BAA` 浮层,可直接 `Pause` / `Resume` / `Drain` 或打开 controller
64@@ -62,4 +81,4 @@
65 - 本次没有改 `manifest.json`
66 - 因此 Control API 需要落在当前扩展 CSP 允许的地址范围内
67 - 当前默认目标就是 `https://control-api.makefile.so`
68-- 可选 WS 能力仍然保留,但默认不启用
69+- 可选 WS 能力仍然保留,但默认不启用,也不是主连接状态来源