- 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
+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");
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
+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 收口
+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,
+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+});
+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 结构,仍需继续跟进模板构造逻辑
+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 ## 现在该读什么