baa-conductor

git clone 

commit
4733478
parent
943b477
author
codex@macbookpro
date
2026-04-01 18:02:18 +0800 CST
feat: protect chatgpt proxy delivery cold starts
7 files changed,  +521, -44
M apps/conductor-daemon/src/index.test.js
+185, -0
  1@@ -4475,6 +4475,191 @@ test("renewal dispatcher classifies downstream proxy_delivery statuses for retry
  2   }
  3 });
  4 
  5+test("renewal dispatcher uses short retries for ChatGPT cold-start template misses and logs warmup recovery", async () => {
  6+  const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-chatgpt-cold-start-"));
  7+  const stateDir = join(rootDir, "state");
  8+  const artifactStore = new ArtifactStore({
  9+    artifactDir: join(stateDir, ARTIFACTS_DIRNAME),
 10+    databasePath: join(stateDir, ARTIFACT_DB_FILENAME)
 11+  });
 12+  const coldStartReason = "delivery.template_missing: missing ChatGPT send template; send one real ChatGPT message first";
 13+  let nowMs = Date.UTC(2026, 2, 30, 12, 45, 0);
 14+  const browserCalls = [];
 15+  const runner = createRenewalDispatcherRunner({
 16+    browserBridge: {
 17+      proxyDelivery(input) {
 18+        browserCalls.push({
 19+          ...input,
 20+          calledAt: nowMs
 21+        });
 22+        const requestId = `proxy-chatgpt-cold-start-${browserCalls.length}`;
 23+
 24+        if (browserCalls.length === 1) {
 25+          return {
 26+            clientId: input.clientId || "firefox-chatgpt",
 27+            connectionId: "conn-firefox-chatgpt",
 28+            dispatchedAt: nowMs,
 29+            requestId,
 30+            result: Promise.resolve(buildProxyDeliveryActionResult({
 31+              accepted: true,
 32+              clientId: input.clientId || "firefox-chatgpt",
 33+              connectionId: "conn-firefox-chatgpt",
 34+              failed: true,
 35+              platform: input.platform,
 36+              reason: coldStartReason,
 37+              receivedAt: nowMs + 20,
 38+              requestId,
 39+              results: []
 40+            })),
 41+            type: "browser.proxy_delivery"
 42+          };
 43+        }
 44+
 45+        return {
 46+          clientId: input.clientId || "firefox-chatgpt",
 47+          connectionId: "conn-firefox-chatgpt",
 48+          dispatchedAt: nowMs,
 49+          requestId,
 50+          result: Promise.resolve(buildProxyDeliveryActionResult({
 51+            clientId: input.clientId || "firefox-chatgpt",
 52+            connectionId: "conn-firefox-chatgpt",
 53+            deliveryAck: buildDeliveryAck(200, {
 54+              confirmed_at: nowMs + 30
 55+            }),
 56+            platform: input.platform,
 57+            receivedAt: nowMs + 40,
 58+            requestId
 59+          })),
 60+          type: "browser.proxy_delivery"
 61+        };
 62+      }
 63+    },
 64+    now: () => nowMs,
 65+    random: () => 0.5,
 66+    retryBaseDelayMs: 30_000,
 67+    retryMaxDelayMs: 30_000
 68+  });
 69+
 70+  try {
 71+    await artifactStore.insertMessage({
 72+      conversationId: "conv_dispatch_chatgpt_cold_start",
 73+      id: "msg_dispatch_chatgpt_cold_start",
 74+      observedAt: nowMs - 60_000,
 75+      platform: "chatgpt",
 76+      rawText: "renewal dispatcher cold start message",
 77+      role: "assistant"
 78+    });
 79+    await artifactStore.upsertLocalConversation({
 80+      automationStatus: "auto",
 81+      localConversationId: "lc_dispatch_chatgpt_cold_start",
 82+      platform: "chatgpt",
 83+      updatedAt: nowMs - 30_000
 84+    });
 85+    await artifactStore.upsertConversationLink({
 86+      clientId: "firefox-chatgpt",
 87+      linkId: "link_dispatch_chatgpt_cold_start",
 88+      localConversationId: "lc_dispatch_chatgpt_cold_start",
 89+      observedAt: nowMs - 30_000,
 90+      pageTitle: "ChatGPT Cold Start",
 91+      pageUrl: "https://chatgpt.com/c/conv_dispatch_chatgpt_cold_start",
 92+      platform: "chatgpt",
 93+      remoteConversationId: "conv_dispatch_chatgpt_cold_start",
 94+      routeParams: {
 95+        conversationId: "conv_dispatch_chatgpt_cold_start"
 96+      },
 97+      routePath: "/c/conv_dispatch_chatgpt_cold_start",
 98+      routePattern: "/c/:conversationId",
 99+      targetId: "tab:61",
100+      targetKind: "browser.proxy_delivery",
101+      targetPayload: {
102+        clientId: "firefox-chatgpt",
103+        conversationId: "conv_dispatch_chatgpt_cold_start",
104+        pageUrl: "https://chatgpt.com/c/conv_dispatch_chatgpt_cold_start",
105+        tabId: 61
106+      }
107+    });
108+    await artifactStore.insertRenewalJob({
109+      jobId: "job_dispatch_chatgpt_cold_start",
110+      localConversationId: "lc_dispatch_chatgpt_cold_start",
111+      maxAttempts: 3,
112+      messageId: "msg_dispatch_chatgpt_cold_start",
113+      nextAttemptAt: nowMs,
114+      payload: "[renewal] cold start recovery",
115+      payloadKind: "text"
116+    });
117+
118+    const firstTick = createTimedJobRunnerContext({
119+      artifactStore
120+    });
121+    const firstResult = await runner.run(firstTick.context);
122+    const pendingJob = await artifactStore.getRenewalJob("job_dispatch_chatgpt_cold_start");
123+
124+    assert.equal(firstResult.result, "ok");
125+    assert.equal(firstResult.details.retried_jobs, 1);
126+    assert.equal(browserCalls.length, 1);
127+    assert.equal(pendingJob.status, "pending");
128+    assert.equal(pendingJob.attemptCount, 1);
129+    assert.equal(pendingJob.lastError, coldStartReason);
130+    assert.equal(pendingJob.nextAttemptAt, nowMs + 5_000);
131+    assert.ok(
132+      firstTick.entries.find(
133+        (entry) =>
134+          entry.stage === "chatgpt_cold_start_delivery"
135+          && entry.result === "waiting_for_template_warmup"
136+          && entry.details?.retry_base_delay_ms === 5_000
137+          && entry.details?.retry_delay_ms === 5_000
138+      )
139+    );
140+    assert.ok(
141+      firstTick.entries.find(
142+        (entry) =>
143+          entry.stage === "job_retry_scheduled"
144+          && entry.result === coldStartReason
145+          && entry.details?.cold_start_waiting_for_template === true
146+          && entry.details?.retry_base_delay_ms === 5_000
147+      )
148+    );
149+
150+    nowMs = pendingJob.nextAttemptAt;
151+
152+    const secondTick = createTimedJobRunnerContext({
153+      artifactStore
154+    });
155+    const secondResult = await runner.run(secondTick.context);
156+    const completedJob = await artifactStore.getRenewalJob("job_dispatch_chatgpt_cold_start");
157+
158+    assert.equal(secondResult.result, "ok");
159+    assert.equal(secondResult.details.successful_jobs, 1);
160+    assert.equal(browserCalls.length, 2);
161+    assert.equal(completedJob.status, "done");
162+    assert.equal(completedJob.attemptCount, 2);
163+    assert.equal(completedJob.lastError, null);
164+    assert.equal(completedJob.nextAttemptAt, null);
165+    assert.ok(
166+      secondTick.entries.find(
167+        (entry) =>
168+          entry.stage === "chatgpt_template_warmup"
169+          && entry.result === "template_warmup_completed"
170+          && entry.details?.previous_error === coldStartReason
171+      )
172+    );
173+    assert.ok(
174+      secondTick.entries.find(
175+        (entry) =>
176+          entry.stage === "job_completed"
177+          && entry.result === "attempt_succeeded"
178+          && entry.details?.recovered_from_cold_start === true
179+      )
180+    );
181+  } finally {
182+    artifactStore.close();
183+    rmSync(rootDir, {
184+      force: true,
185+      recursive: true
186+    });
187+  }
188+});
189+
190 test("renewal dispatcher success writes cooldownUntil and blocks projector from projecting follow-up messages during cooldown", async () => {
191   const rootDir = mkdtempSync(join(tmpdir(), "baa-renewal-dispatcher-cooldown-chain-"));
192   const stateDir = join(rootDir, "state");
M apps/conductor-daemon/src/renewal/dispatcher.ts
+71, -2
  1@@ -33,7 +33,10 @@ const DEFAULT_INTER_JOB_JITTER_MAX_MS = 3_000;
  2 const DEFAULT_RETRY_BASE_DELAY_MS = 30_000;
  3 const DEFAULT_RETRY_JITTER_FACTOR = 0.3;
  4 const DEFAULT_RETRY_MAX_DELAY_MS = 5 * 60_000;
  5+const CHATGPT_COLD_START_RETRY_BASE_DELAY_MS = 5_000;
  6+const CHATGPT_COLD_START_RETRY_MAX_DELAY_MS = 30_000;
  7 const DEFAULT_SUCCESS_COOLDOWN_MS = 60_000;
  8+const CHATGPT_PLATFORM = "chatgpt";
  9 const PROXY_DELIVERY_TARGET_KIND = "browser.proxy_delivery";
 10 const RUNNER_NAME = "renewal.dispatcher";
 11 const TAB_TARGET_ID_PATTERN = /^tab:(\d+)$/u;
 12@@ -433,6 +436,10 @@ export async function runRenewalDispatcher(
 13       const finishedAt = now();
 14       const attemptCount = job.attemptCount + 1;
 15       const cooldownUntil = finishedAt + resolveSuccessCooldownMs(options.successCooldownMs, context.config.intervalMs);
 16+      const recoveredFromColdStart = isChatgptColdStartFailureMessage(
 17+        dispatchContext.target.platform,
 18+        job.lastError
 19+      );
 20 
 21       await artifactStore.upsertLocalConversation({
 22         cooldownUntil,
 23@@ -459,6 +466,20 @@ export async function runRenewalDispatcher(
 24         store: artifactStore
 25       });
 26       successfulJobs += 1;
 27+      if (recoveredFromColdStart) {
 28+        context.log({
 29+          stage: "chatgpt_template_warmup",
 30+          result: "template_warmup_completed",
 31+          details: {
 32+            attempt_count: attemptCount,
 33+            job_id: job.jobId,
 34+            local_conversation_id: job.localConversationId,
 35+            message_id: job.messageId,
 36+            recovered_at: finishedAt,
 37+            previous_error: job.lastError
 38+          }
 39+        });
 40+      }
 41       context.log({
 42         stage: "job_completed",
 43         result: "attempt_succeeded",
 44@@ -472,20 +493,29 @@ export async function runRenewalDispatcher(
 45           message_id: job.messageId,
 46           proxy_request_id: delivery.dispatch.requestId,
 47           received_at: delivery.result.received_at,
 48+          recovered_from_cold_start: recoveredFromColdStart || undefined,
 49           status_confirmed_at: delivery.deliveryAck.confirmed_at
 50         }
 51       });
 52     } catch (error) {
 53       const failure = normalizeExecutionFailure(error);
 54       const attempts = job.attemptCount + 1;
 55+      const coldStartFailure = isChatgptColdStartFailureMessage(
 56+        dispatchContext.target.platform,
 57+        failure.result
 58+      );
 59       const failureResult = await applyFailureOutcome(artifactStore, job, {
 60         attemptCount: attempts,
 61         errorMessage: failure.result,
 62         logDir: context.logDir,
 63         now: now(),
 64-        retryBaseDelayMs: options.retryBaseDelayMs,
 65+        retryBaseDelayMs: coldStartFailure
 66+          ? CHATGPT_COLD_START_RETRY_BASE_DELAY_MS
 67+          : options.retryBaseDelayMs,
 68         retryJitterFactor: jitterSettings.retryJitterFactor,
 69-        retryMaxDelayMs: options.retryMaxDelayMs,
 70+        retryMaxDelayMs: coldStartFailure
 71+          ? CHATGPT_COLD_START_RETRY_MAX_DELAY_MS
 72+          : options.retryMaxDelayMs,
 73         random: jitterSettings.random,
 74         targetSnapshot: dispatchContext.targetSnapshot
 75       });
 76@@ -495,6 +525,30 @@ export async function runRenewalDispatcher(
 77         observedAt: now(),
 78         store: artifactStore
 79       });
 80+      if (coldStartFailure) {
 81+        context.log({
 82+          stage: "chatgpt_cold_start_delivery",
 83+          result: failureResult.status === "pending"
 84+            ? "waiting_for_template_warmup"
 85+            : "template_warmup_exhausted",
 86+          details: {
 87+            attempt_count: attempts,
 88+            error_message: failure.message,
 89+            job_id: job.jobId,
 90+            local_conversation_id: job.localConversationId,
 91+            message_id: job.messageId,
 92+            next_attempt_at: failureResult.status === "pending"
 93+              ? failureResult.nextAttemptAt
 94+              : undefined,
 95+            retry_base_delay_ms: failureResult.status === "pending"
 96+              ? failureResult.baseDelayMs
 97+              : undefined,
 98+            retry_delay_ms: failureResult.status === "pending"
 99+              ? failureResult.delayMs
100+              : undefined
101+          }
102+        });
103+      }
104 
105       if (failureResult.status === "failed") {
106         failedJobs += 1;
107@@ -510,6 +564,7 @@ export async function runRenewalDispatcher(
108             error_message: failure.message,
109             job_id: job.jobId,
110             message_id: job.messageId,
111+            cold_start_waiting_for_template: coldStartFailure || undefined,
112             timeout_ms: failure.timeoutMs
113           }
114         });
115@@ -531,6 +586,7 @@ export async function runRenewalDispatcher(
116             retry_base_delay_ms: failureResult.baseDelayMs,
117             retry_delay_ms: failureResult.delayMs,
118             retry_jitter_ms: failureResult.jitterMs,
119+            cold_start_waiting_for_template: coldStartFailure || undefined,
120             timeout_ms: failure.timeoutMs
121           }
122         });
123@@ -1003,6 +1059,19 @@ function isRetryableFailure(message: string): boolean {
124   ].includes(message);
125 }
126 
127+function isChatgptColdStartFailureMessage(
128+  platform: string | null | undefined,
129+  message: string | null | undefined
130+): boolean {
131+  const normalizedMessage = normalizeOptionalString(message);
132+
133+  if (platform !== CHATGPT_PLATFORM || normalizedMessage == null) {
134+    return false;
135+  }
136+
137+  return /^delivery\.template_(?:invalid|missing)\b/u.test(normalizedMessage);
138+}
139+
140 function parseDownstreamStatusCode(message: string): number | null {
141   const match = /^downstream_status_(\d{3})$/u.exec(message.trim());
142 
M plans/STATUS_SUMMARY.md
+12, -18
 1@@ -6,7 +6,7 @@
 2 
 3 ## 当前代码基线
 4 
 5-- 当前主分支:`main@264650f`
 6+- 当前主分支:`main@943b477`
 7 - canonical local API:`http://100.71.210.78:4317`
 8 - canonical public host:`https://conductor.makefile.so`
 9 - 活跃任务文档和近期刚完成的任务文档保留在 `tasks/` 根目录;较早已完成任务归档到 [`../tasks/archive/README.md`](../tasks/archive/README.md)
10@@ -52,6 +52,7 @@
11   - timed-jobs JSONL 日志现在已改为异步写入,减少 tick 周期内的同步 IO 阻塞
12   - browser request 风控状态现在会持久化到 `artifact.db`,重启后会恢复限流/退避/熔断窗口,并回收遗留执行锁与 `running` renewal job
13   - `proxy_delivery` 结果现在会异步补齐 Level 1 下游 HTTP 状态码;renewal dispatcher 仅在 `200` 时标记 `done`,`429/5xx` 改为 `retry`
14+  - ChatGPT send template 现在会持久化到插件本地缓存;controller 重载后会恢复最近模板,renewal dispatcher 对模板缺失改为 5s 起步短延迟 retry,并显式记录冷启动/预热完成日志
15 
16 ## 当前已纠正的文档/代码不一致
17 
18@@ -72,27 +73,20 @@
19 
20 **当前下一波任务:**
21 
22-1. `T-S068`:ChatGPT proxy send 冷启动降级保护
23-2. `T-S065`:policy 配置化
24-3. `T-S067`:Gemini 正式接入 raw relay 支持面
25-4. `OPT-004`:Claude final-message 更稳 fallback
26-5. `OPT-009`:renewal 模块重复工具函数抽取
27-
28-并行需要持续关注:
29-
30-- `T-S068` 继续会碰 delivery 路径,尽量避免与同批 delivery 收口项并行改同一批文件
31+1. `T-S065`:policy 配置化
32+2. `T-S067`:Gemini 正式接入 raw relay 支持面
33+3. `OPT-004`:Claude final-message 更稳 fallback
34+4. `OPT-009`:renewal 模块重复工具函数抽取
35 
36 **并行优化项:**
37 
38-1. `T-S068`
39-   给 ChatGPT proxy send 补冷启动保护,避免插件重载后首批 delivery 直接失败
40-2. `T-S065`
41+1. `T-S065`
42    让 policy 白名单配置化,为后续 automation control 指令扩面铺路
43-3. `T-S067`
44+2. `T-S067`
45    把 Gemini 提升到正式 raw relay 支持面,减少 helper/proxy mix 带来的脆弱性
46-4. `OPT-004`
47+3. `OPT-004`
48    为 Claude final-message 增加更稳的 SSE fallback
49-5. `OPT-009`
50+4. `OPT-009`
51    renewal 模块重复工具函数抽取,减少重复逻辑
52 
53 **已关闭的优化项:**
54@@ -143,10 +137,10 @@ Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续
55 ## 当前仍需关注
56 
57 - `Gemini` 当前仍不是 `/v1/browser/request` 的正式 raw relay 支持面;`@browser.gemini` 走 helper / proxy mix,仍需依赖最近观测到的真实请求上下文
58-- ChatGPT proxy send 仍依赖最近捕获的真实发送模板;如果 controller 刚重载且还没观察到真实发送,会退回同页 DOM fallback
59+- ChatGPT proxy send 虽已持久化最近模板并在冷启动时走短延迟 retry,但如果 controller 重载前本地没有任何可恢复模板,首批 job 仍需等待下一次真实 ChatGPT 发送完成预热
60 - Claude 的 `organizationId` 当前仍依赖最近观测到的 org 上下文,不是完整的多页多 org 精确映射
61 - `proxy_delivery` 当前已补齐 Level 1 HTTP 接受确认,但仍不是“下游 AI 已完整回复”;Level 3 仍依赖后续 final-message 链路
62 - ChatGPT root message / mapping 结构如果后续变化,final-message 提取启发式仍需跟进
63 - recent relay cache 是有限窗口;极老 replay 超出窗口后,仍会落回 conductor dedupe
64 - `status-api` 继续保留为显式 opt-in 兼容层,不是当前删除重点
65-- 以上几项风险现主要拆成 `T-S067`、`T-S068` 跟踪;`T-S069` 已完成首版 Level 1 收口
66+- 以上几项风险现主要拆成 `T-S067` 跟踪;`T-S068`、`T-S069` 已分别完成冷启动保护和首版 Level 1 收口
M plugins/baa-firefox/controller.js
+123, -11
  1@@ -23,6 +23,7 @@ const CONTROLLER_STORAGE_KEYS = {
  2   lastCredentialTabIdByPlatform: "baaFirefox.lastCredentialTabIdByPlatform",
  3   credentialFingerprintByPlatform: "baaFirefox.credentialFingerprintByPlatform",
  4   accountByPlatform: "baaFirefox.accountByPlatform",
  5+  chatgptSendTemplates: "baaFirefox.chatgptSendTemplates",
  6   geminiSendTemplate: "baaFirefox.geminiSendTemplate",
  7   claudeState: "baaFirefox.claudeState",
  8   finalMessageRelayCache: "baaFirefox.finalMessageRelayCache"
  9@@ -67,6 +68,8 @@ const CLAUDE_THINKING_START_RE = /^(The user|Let me|I need to|I should|I'll|Geor
 10 const SHELL_TAB_HASH = "#baa-shell";
 11 const REDACTED_CREDENTIAL_VALUE = "[redacted]";
 12 const ACCOUNT_EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
 13+const CHATGPT_SEND_TEMPLATE_LIMIT = 12;
 14+const CHATGPT_SEND_TEMPLATE_TTL = CREDENTIAL_TTL;
 15 const CHATGPT_SESSION_COOKIE_PATTERNS = [
 16   /__secure-next-auth\.session-token=/i,
 17   /__secure-authjs\.session-token=/i,
 18@@ -3223,14 +3226,83 @@ function cloneJsonValue(value) {
 19   }
 20 }
 21 
 22-function pruneChatgptSendTemplates(limit = 12) {
 23-  const entries = Object.entries(state.chatgptSendTemplates || {})
 24-    .filter(([, entry]) => isRecord(entry))
 25-    .sort(([, left], [, right]) => (Number(right.updatedAt) || 0) - (Number(left.updatedAt) || 0));
 26+function normalizeChatgptSendTemplateEntry(value, fallbackConversationId = null, now = Date.now()) {
 27+  if (!isRecord(value)) {
 28+    return null;
 29+  }
 30+
 31+  const conversationId = trimToNull(value.conversationId) || trimToNull(fallbackConversationId);
 32+  const reqBody = typeof value.reqBody === "string" && value.reqBody.trim()
 33+    ? value.reqBody
 34+    : null;
 35+  const updatedAt = Number.isFinite(Number(value.updatedAt)) && Number(value.updatedAt) > 0
 36+    ? Math.round(Number(value.updatedAt))
 37+    : 0;
 38 
 39-  for (const [conversationId] of entries.slice(limit)) {
 40-    delete state.chatgptSendTemplates[conversationId];
 41+  if (!conversationId || !reqBody || updatedAt <= 0 || now - updatedAt > CHATGPT_SEND_TEMPLATE_TTL) {
 42+    return null;
 43   }
 44+
 45+  try {
 46+    const parsed = JSON.parse(reqBody);
 47+
 48+    if (!isRecord(parsed)) {
 49+      return null;
 50+    }
 51+  } catch (_) {
 52+    return null;
 53+  }
 54+
 55+  return {
 56+    conversationId,
 57+    credentialFingerprint: trimToNull(value.credentialFingerprint),
 58+    model: trimToNull(value.model),
 59+    pageUrl: trimToNull(value.pageUrl),
 60+    reqBody,
 61+    updatedAt
 62+  };
 63+}
 64+
 65+function loadChatgptSendTemplates(raw, now = Date.now()) {
 66+  const source = isRecord(raw) ? raw : {};
 67+  const normalized = [];
 68+
 69+  for (const [conversationId, entry] of Object.entries(source)) {
 70+    const template = normalizeChatgptSendTemplateEntry(entry, conversationId, now);
 71+
 72+    if (template) {
 73+      normalized.push(template);
 74+    }
 75+  }
 76+
 77+  normalized.sort((left, right) => right.updatedAt - left.updatedAt);
 78+
 79+  const next = {};
 80+  for (const entry of normalized.slice(0, CHATGPT_SEND_TEMPLATE_LIMIT)) {
 81+    next[entry.conversationId] = entry;
 82+  }
 83+
 84+  return next;
 85+}
 86+
 87+function serializeChatgptSendTemplates(now = Date.now()) {
 88+  return loadChatgptSendTemplates(state.chatgptSendTemplates, now);
 89+}
 90+
 91+function pruneChatgptSendTemplates(now = Date.now()) {
 92+  state.chatgptSendTemplates = loadChatgptSendTemplates(state.chatgptSendTemplates, now);
 93+}
 94+
 95+function invalidateChatgptSendTemplate(conversationId) {
 96+  const normalizedConversationId = trimToNull(conversationId);
 97+
 98+  if (!normalizedConversationId || !state.chatgptSendTemplates[normalizedConversationId]) {
 99+    return false;
100+  }
101+
102+  delete state.chatgptSendTemplates[normalizedConversationId];
103+  persistState().catch(() => {});
104+  return true;
105 }
106 
107 function rememberChatgptSendTemplate(context, reqBody) {
108@@ -3253,22 +3325,32 @@ function rememberChatgptSendTemplate(context, reqBody) {
109 
110   const next = {
111     conversationId,
112+    credentialFingerprint: trimToNull(state.credentialFingerprint.chatgpt),
113     model: trimToNull(parsed.model),
114     pageUrl: trimToNull(context?.senderUrl),
115     reqBody,
116     updatedAt: Date.now()
117   };
118   const previous = state.chatgptSendTemplates[conversationId];
119+  const hadUsableTemplate = normalizeChatgptSendTemplateEntry(previous, conversationId, next.updatedAt) != null;
120   const changed = !previous
121     || previous.reqBody !== next.reqBody
122+    || previous.credentialFingerprint !== next.credentialFingerprint
123     || previous.pageUrl !== next.pageUrl
124     || previous.model !== next.model;
125 
126   state.chatgptSendTemplates[conversationId] = next;
127-  pruneChatgptSendTemplates();
128+  pruneChatgptSendTemplates(next.updatedAt);
129+  persistState().catch(() => {});
130 
131   if (changed) {
132-    addLog("info", `已捕获 ChatGPT 发送模板 conversation=${conversationId}`, false);
133+    addLog(
134+      "info",
135+      hadUsableTemplate
136+        ? `已更新 ChatGPT 发送模板 conversation=${conversationId}`
137+        : `ChatGPT 发送模板预热完成 conversation=${conversationId}`,
138+      false
139+    );
140   }
141 
142   return true;
143@@ -3281,10 +3363,31 @@ function getChatgptSendTemplate(conversationId) {
144     return null;
145   }
146 
147-  const template = state.chatgptSendTemplates[normalizedConversationId];
148-  return isRecord(template) ? {
149+  const template = normalizeChatgptSendTemplateEntry(
150+    state.chatgptSendTemplates[normalizedConversationId],
151+    normalizedConversationId
152+  );
153+
154+  if (!template) {
155+    invalidateChatgptSendTemplate(normalizedConversationId);
156+    return null;
157+  }
158+
159+  const currentFingerprint = trimToNull(state.credentialFingerprint.chatgpt);
160+
161+  if (
162+    template.credentialFingerprint
163+    && currentFingerprint
164+    && template.credentialFingerprint !== currentFingerprint
165+  ) {
166+    invalidateChatgptSendTemplate(normalizedConversationId);
167+    return null;
168+  }
169+
170+  state.chatgptSendTemplates[normalizedConversationId] = template;
171+  return {
172     ...template
173-  } : null;
174+  };
175 }
176 
177 function buildChatgptDeliveryRequest(options = {}) {
178@@ -3563,6 +3666,7 @@ async function persistState() {
179     [CONTROLLER_STORAGE_KEYS.lastCredentialTabIdByPlatform]: state.lastCredentialTabId,
180     [CONTROLLER_STORAGE_KEYS.credentialFingerprintByPlatform]: state.credentialFingerprint,
181     [CONTROLLER_STORAGE_KEYS.accountByPlatform]: state.account,
182+    [CONTROLLER_STORAGE_KEYS.chatgptSendTemplates]: serializeChatgptSendTemplates(),
183     [CONTROLLER_STORAGE_KEYS.geminiSendTemplate]: state.geminiSendTemplate,
184     [CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]: serializeFinalMessageRelayCache(),
185     [CONTROLLER_STORAGE_KEYS.claudeState]: {
186@@ -7959,6 +8063,9 @@ async function init() {
187   state.account = loadAccountMap(
188     saved[CONTROLLER_STORAGE_KEYS.accountByPlatform]
189   );
190+  state.chatgptSendTemplates = loadChatgptSendTemplates(
191+    saved[CONTROLLER_STORAGE_KEYS.chatgptSendTemplates]
192+  );
193   state.geminiSendTemplate = saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate] || null;
194   restoreFinalMessageRelayCache(saved[CONTROLLER_STORAGE_KEYS.finalMessageRelayCache]);
195   state.claudeState = loadClaudeState(saved[CONTROLLER_STORAGE_KEYS.claudeState]);
196@@ -8056,17 +8163,21 @@ function exposeControllerTestApi() {
197   }
198 
199   Object.assign(target, {
200+    buildChatgptDeliveryRequest,
201     buildPageControlSnapshotForSender,
202     connectWs,
203     createPluginDiagnosticPayload,
204     flushBufferedPluginDiagnosticLogs,
205+    getChatgptSendTemplate,
206     getSenderContext,
207     handlePageBridgeReady,
208     handlePageDiagnosticLog,
209     handlePageNetwork,
210     handlePageSse,
211     handleWsStateSnapshot,
212+    loadChatgptSendTemplates,
213     persistFinalMessageRelayCache,
214+    rememberChatgptSendTemplate,
215     reinjectAllOpenPlatformTabs,
216     reinjectPlatformTabs,
217     restoreFinalMessageRelayCache,
218@@ -8075,6 +8186,7 @@ function exposeControllerTestApi() {
219     runPageControlAction,
220     runPluginManagementAction,
221     sendPluginDiagnosticLog,
222+    serializeChatgptSendTemplates,
223     serializeFinalMessageRelayCache,
224     setDesiredTabState,
225     syncPageControlFromContext,
M plugins/baa-firefox/controller.test.cjs
+100, -0
  1@@ -77,6 +77,7 @@ function createControllerHarness(options = {}) {
  2     URL,
  3     URLSearchParams,
  4     WebSocket: FakeWebSocket,
  5+    crypto: globalThis.crypto,
  6     browser: {
  7       runtime: {
  8         onMessage: {
  9@@ -358,3 +359,102 @@ test("controller applies websocket automation snapshots to control and page stat
 10   assert.equal(pageState.pauseReason, "repeated_message");
 11   assert.equal(pageState.paused, true);
 12 });
 13+
 14+test("controller restores persisted ChatGPT send templates and reuses them for proxy delivery", () => {
 15+  const nowMs = Date.UTC(2026, 3, 1, 10, 0, 0);
 16+  const { api } = createControllerHarness();
 17+
 18+  api.state.credentialFingerprint.chatgpt = "fp-chatgpt-1";
 19+  api.rememberChatgptSendTemplate({
 20+    conversationId: "conv-persisted",
 21+    senderUrl: "https://chatgpt.com/c/conv-persisted"
 22+  }, JSON.stringify({
 23+    action: "next",
 24+    conversation_id: "conv-persisted",
 25+    messages: [
 26+      {
 27+        author: {
 28+          role: "user"
 29+        },
 30+        content: {
 31+          content_type: "text",
 32+          parts: ["hello"]
 33+        },
 34+        id: "msg-template"
 35+      }
 36+    ],
 37+    model: "gpt-4o",
 38+    websocket_request_id: "request-template"
 39+  }));
 40+
 41+  const savedTemplates = api.serializeChatgptSendTemplates(nowMs);
 42+  const { api: restoredApi } = createControllerHarness();
 43+
 44+  restoredApi.state.chatgptSendTemplates = restoredApi.loadChatgptSendTemplates(savedTemplates, nowMs);
 45+  restoredApi.state.credentialFingerprint.chatgpt = "fp-chatgpt-1";
 46+  restoredApi.state.trackedTabs.chatgpt = 17;
 47+  restoredApi.state.lastHeaders.chatgpt = {
 48+    authorization: "Bearer test-token",
 49+    cookie: "__Secure-next-auth.session-token=test-session",
 50+    "x-openai-assistant-app-id": "chatgpt"
 51+  };
 52+  restoredApi.state.credentialCapturedAt.chatgpt = nowMs;
 53+  restoredApi.state.lastCredentialAt.chatgpt = nowMs;
 54+  restoredApi.state.lastCredentialTabId.chatgpt = 17;
 55+  restoredApi.state.lastCredentialUrl.chatgpt = "https://chatgpt.com/backend-api/conversation";
 56+
 57+  const template = restoredApi.getChatgptSendTemplate("conv-persisted");
 58+  const request = restoredApi.buildChatgptDeliveryRequest({
 59+    conversationId: "conv-persisted",
 60+    messageText: "follow-up from persisted template",
 61+    sourceAssistantMessageId: "assistant-msg-1"
 62+  });
 63+
 64+  assert.ok(template);
 65+  assert.equal(template.credentialFingerprint, "fp-chatgpt-1");
 66+  assert.equal(request.method, "POST");
 67+  assert.equal(request.path, "/backend-api/conversation");
 68+  assert.equal(request.body.conversation_id, "conv-persisted");
 69+  assert.equal(request.body.model, "gpt-4o");
 70+  assert.equal(request.body.parent_message_id, "assistant-msg-1");
 71+  assert.equal(request.body.messages[0].author.role, "user");
 72+  assert.equal(request.body.messages[0].content.parts[0], "follow-up from persisted template");
 73+  assert.equal(request.headers.accept, "text/event-stream");
 74+});
 75+
 76+test("controller invalidates persisted ChatGPT templates when the credential fingerprint changes", () => {
 77+  const nowMs = Date.UTC(2026, 3, 1, 10, 5, 0);
 78+  const { api } = createControllerHarness();
 79+
 80+  api.state.chatgptSendTemplates = api.loadChatgptSendTemplates({
 81+    "conv-fingerprint-mismatch": {
 82+      conversationId: "conv-fingerprint-mismatch",
 83+      credentialFingerprint: "fp-old",
 84+      model: "gpt-4o",
 85+      pageUrl: "https://chatgpt.com/c/conv-fingerprint-mismatch",
 86+      reqBody: JSON.stringify({
 87+        action: "next",
 88+        conversation_id: "conv-fingerprint-mismatch",
 89+        messages: [
 90+          {
 91+            author: {
 92+              role: "user"
 93+            },
 94+            content: {
 95+              content_type: "text",
 96+              parts: ["hello"]
 97+            },
 98+            id: "msg-template"
 99+          }
100+        ]
101+      }),
102+      updatedAt: nowMs
103+    }
104+  }, nowMs);
105+  api.state.credentialFingerprint.chatgpt = "fp-new";
106+
107+  const template = api.getChatgptSendTemplate("conv-fingerprint-mismatch");
108+
109+  assert.equal(template, null);
110+  assert.equal(Object.keys(api.serializeChatgptSendTemplates(nowMs)).length, 0);
111+});
M tasks/T-S068.md
+24, -6
 1@@ -2,7 +2,7 @@
 2 
 3 ## 状态
 4 
 5-- 当前状态:`待开始`
 6+- 当前状态:`已完成`
 7 - 规模预估:`S`
 8 - 依赖任务:无
 9 - 建议执行者:`Codex`
10@@ -77,22 +77,40 @@ ChatGPT proxy send 依赖最近捕获的真实发送模板(请求头、cookie
11 
12 ### 开始执行
13 
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-04-01 17:23:21 CST`
18 - 状态变更:`待开始` → `进行中`
19 
20 ### 完成摘要
21 
22-- 完成时间:
23+- 完成时间:`2026-04-01 17:59:25 CST`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+  - `plugins/baa-firefox/controller.js`
27+  - `plugins/baa-firefox/controller.test.cjs`
28+  - `apps/conductor-daemon/src/renewal/dispatcher.ts`
29+  - `apps/conductor-daemon/src/index.test.js`
30+  - `tasks/T-S068.md`
31+  - `tasks/TASK_OVERVIEW.md`
32+  - `plans/STATUS_SUMMARY.md`
33 - 核心实现思路:
34+  - 先收敛 ChatGPT proxy send 的最小依赖集:真正必须从真实请求继承的是最近一次可用的 `reqBody/model` 模板;登录态请求头、cookie、CSRF 仍继续复用现有持久化 credential snapshot;`message_id`、`parent_message_id`、`websocket_request_id` 则在每次 proxy delivery 时动态重建
35+  - 在 Firefox controller 侧把最近 12 条 ChatGPT send template 持久化到 `browser.storage.local`,并在恢复时做 TTL 和 credential fingerprint 校验;controller 重载后可直接恢复最近模板,不需要额外触发只读预热请求
36+  - 在 renewal dispatcher 侧把 `delivery.template_missing` / `delivery.template_invalid` 识别为 ChatGPT 冷启动失败,改成 5s 起步短延迟 retry,并新增 `chatgpt_cold_start_delivery` / `chatgpt_template_warmup` 日志事件,区分“冷启动等待预热”和“模板预热完成”
37 - 跑了哪些测试:
38+  - `pnpm install`
39+  - `node --check plugins/baa-firefox/controller.js`
40+  - `node --test plugins/baa-firefox/controller.test.cjs`
41+  - `pnpm -C apps/conductor-daemon build`
42+  - `node --test --test-name-pattern "renewal dispatcher uses short retries for ChatGPT cold-start template misses and logs warmup recovery|renewal dispatcher classifies downstream proxy_delivery statuses for retry and terminal failure|renewal dispatcher sends due pending jobs through browser.proxy_delivery and marks them done" apps/conductor-daemon/src/index.test.js`
43+  - `pnpm -C apps/conductor-daemon test`
44 
45 ### 执行过程中遇到的问题
46 
47-- 
48+- 新 worktree 初始没有 `node_modules`,先执行了 `pnpm install` 后才可跑 `tsc` 和完整 `node --test`
49+- `node --test` 的筛选参数首轮传法不对,改为直接使用 `node --test --test-name-pattern ... src/index.test.js` 后完成了定向验证
50 
51 ### 剩余风险
52 
53-- 
54+- ChatGPT 模板持久化只覆盖最近 12 个 conversation,且 TTL 跟随现有 credential TTL;如果 controller 冷启动前本地没有任何可恢复模板,首批 job 仍会先进入短延迟 retry,等待下一次真实 ChatGPT 发送完成预热
55+- 持久化模板只解决“最近模板丢失”,不改变 ChatGPT 上游协议结构;若 ChatGPT 后续调整 `/backend-api/conversation` body 结构,仍需继续跟进模板构造逻辑
M tasks/TASK_OVERVIEW.md
+6, -7
 1@@ -3,7 +3,7 @@
 2 ## 当前基线
 3 
 4 - 日期:`2026-04-01`
 5-- 主分支基线:`main@264650f`
 6+- 主分支基线:`main@943b477`
 7 - canonical local API:`http://100.71.210.78:4317`
 8 - canonical public host:`https://conductor.makefile.so`
 9 - 当前活跃任务卡和近期刚完成的任务卡保留在本目录;较早已完成任务归档到 [`./archive/README.md`](./archive/README.md)
10@@ -50,6 +50,7 @@
11   - 系统级 automation pause 已落地:`system paused` 会同时阻断 live BAA 指令和 timed-jobs 主链,且不会覆盖各对话原有 `pause_reason`
12   - browser request 风控状态现在会持久化到 `artifact.db`,重启后会恢复限流/退避/熔断窗口,并清理遗留执行锁与 `running` renewal job
13   - `proxy_delivery` 结果现在会异步补齐 Level 1 下游 HTTP 状态码;renewal dispatcher 仅在 `200` 时标记 `done`,`429/5xx` 改为 `retry`
14+  - ChatGPT send template 现在会持久化到插件本地缓存;controller 重载后会恢复最近模板,renewal dispatcher 对模板缺失改为 5s 起步短延迟 retry,并显式记录冷启动/预热完成日志
15 
16 ## 当前已确认的不一致
17 
18@@ -85,13 +86,13 @@
19 | [`T-S063`](./T-S063.md) | normalize / parse 错误隔离 | S | 无 | Codex | 已完成 |
20 | [`T-S064`](./T-S064.md) | timed-jobs 异步日志写入 | S | 无 | Codex | 已完成 |
21 | [`T-S066`](./T-S066.md) | 风控状态持久化 | M | T-S060 | Codex | 已完成 |
22+| [`T-S068`](./T-S068.md) | ChatGPT proxy send 冷启动降级保护 | S | 无 | Codex | 已完成 |
23 | [`T-S069`](./T-S069.md) | proxy_delivery 成功语义增强 | L | T-S060 | Codex | 已完成 |
24 
25 ### 当前下一波任务
26 
27 | 项目 | 标题 | 类型 | 状态 | 说明 |
28 |---|---|---|---|---|
29-| [`T-S068`](./T-S068.md) | ChatGPT proxy send 冷启动降级保护 | task | 待开始 | 减少插件重载后首批 delivery 直接失败或退回 DOM fallback |
30 | [`T-S065`](./T-S065.md) | policy 配置化 | task | 待开始 | 为自动化控制指令和后续扩面提供策略入口 |
31 | [`T-S067`](./T-S067.md) | Gemini 正式接入 raw relay 支持面 | task | 待开始 | 把 `@browser.gemini` 提升到稳定 raw relay 支持面 |
32 | [`../bugs/OPT-004-final-message-claude-sse-fallback.md`](../bugs/OPT-004-final-message-claude-sse-fallback.md) | Claude final-message SSE fallback | opt | open | 降低上游 SSE 协议变化的脆弱性 |
33@@ -143,7 +144,6 @@
34 
35 ### P1(并行优化)
36 
37-- [`T-S068`](./T-S068.md)
38 - [`T-S065`](./T-S065.md)
39 - [`T-S067`](./T-S067.md)
40 - [`../bugs/OPT-004-final-message-claude-sse-fallback.md`](../bugs/OPT-004-final-message-claude-sse-fallback.md)
41@@ -182,11 +182,10 @@
42 
43 ## 当前主线判断
44 
45-Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060`、`T-S061`、`T-S062`、`T-S063`、`T-S064`、`T-S066`、`T-S069` 已经落地。当前主线已经没有 open bug blocker,下一步是:
46+Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060`、`T-S061`、`T-S062`、`T-S063`、`T-S064`、`T-S066`、`T-S068`、`T-S069` 已经落地。当前主线已经没有 open bug blocker,下一步是:
47 
48-- 先做 `T-S068`
49-- 再做 `T-S065`
50-- 最后推进 `T-S067`
51+- 先做 `T-S065`
52+- 再推进 `T-S067`
53 - `OPT-004`、`OPT-009` 继续保留为 open opt
54 
55 ## 现在该读什么