- commit
- 3f8e273
- parent
- c772d0c
- author
- codex@macbookpro
- date
- 2026-03-27 14:36:50 +0800 CST
fix: resolve BUG-011 BUG-012 and BUG-014
19 files changed,
+844,
-233
+39,
-24
1@@ -2,11 +2,11 @@
2
3 ## 结论摘要
4
5-- 当前“功能代码基线”按已提交代码看是 `main@25be868`,主题是 `restore managed firefox shell tabs on startup`。
6+- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-014` 修复。
7 - 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude 代发 + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
8 - 代码和自动化测试都表明:`/describe/business`、`/describe/control`、`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel` 已经形成正式主链路。
9 - 目前不应再把系统描述成“只有 Claude 专用页面路径”;当前是“通用 browser surface 已落地,但正式 relay 仍只有 Claude 接通,其它平台主要停留在空壳页和元数据链路”。
10-- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、`BUG-011` 到 `BUG-014` 仍待修、`BUG-014` 当前代码仍然存在、风控状态仍是进程内内存态。
11+- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、open backlog 还剩 `BUG-013` 和 `BUG-017`、风控状态仍是进程内内存态,以及 `BUG-012` 还没有 stale `inFlight` 自动清扫机制。
12
13 ## 本次核对依据
14
15@@ -25,7 +25,7 @@
16 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
17 - 结果:通过
18 - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
19- - 结果:`25/25` 通过
20+ - 结果:`31/31` 通过
21 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
22 - 结果:`3/3` 通过
23
24@@ -172,6 +172,18 @@
25 - `../package.json:5-13`
26 - `../scripts/runtime/verify-mini.sh`
27
28+### 10. `BUG-011`、`BUG-012`、`BUG-014` 已在当前代码中修复
29+
30+- `BUG-011`
31+ - `../apps/conductor-daemon/src/index.ts` 已新增 `awaitWritableDrainOrClose(...)`
32+ - `../apps/conductor-daemon/src/index.test.js` 已覆盖 body close、stream close、drain 后继续写入
33+- `BUG-012`
34+ - `../apps/conductor-daemon/src/browser-request-policy.ts` 已增加 waiter timeout、队列清理和 `waiter_timeout` 错误细节
35+ - `../apps/conductor-daemon/src/index.test.js` 已覆盖 target slot timeout、platform admission timeout 和 HTTP 503 返回路径
36+- `BUG-014`
37+ - `../plugins/baa-firefox/controller.js` 的 `ws_reconnect` 已改为 deferred 结果
38+ - `../tests/browser/browser-control-e2e-smoke.test.mjs` 已覆盖 `ws_reconnect.completed === false`
39+
40 ## 当前未完成 / 待复核
41
42 ### 1. 真实 Firefox 手工 smoke 仍未完成
43@@ -179,13 +191,12 @@
44 - 代码和任务文档里都没有新增“真实 Firefox.app 上手动关 tab -> tab_restore -> WS 重连 -> 状态恢复”的实测结论。
45 - 当前自动化 smoke 是 bridge / relay / persistence 层面的模拟 E2E,不等于真实 Firefox 桌面手工验收。
46
47-### 2. `BUG-011` 到 `BUG-014` 仍然是 open backlog
48+### 2. open backlog 现在只剩 `BUG-013` 和 `BUG-017`
49
50 - 当前不能写成“bug backlog 已清空”。
51-- 尤其 `BUG-014` 目前从代码可直接确认仍存在:
52- - `../plugins/baa-firefox/controller.js:3384-3389` 的 `ws_reconnect` 仍通过 `setTimeout` 延后真正重连
53- - `../plugins/baa-firefox/controller.js:3254-3256` 里 `completed` 默认仍会按 `true` 回传
54-- 这意味着 `ws_reconnect` 的 `action_result.completed` 语义现在仍然偏早。
55+- 当前仍然 open 的是:
56+ - `BUG-013`:stream session timer 未清除
57+ - `BUG-017`:buffered 模式请求 SSE 端点时仍返回原始 SSE 文本
58
59 ### 3. 正式 browser relay 仍然只有 Claude 接通
60
61@@ -200,29 +211,33 @@
62 - 默认策略本身已实现。
63 - 但策略运行态计数并未持久化,进程重启后限流 / 退避 / 熔断状态会重置。
64
65-### 5. 启动自动恢复逻辑目前主要靠代码核对,不是现成自动化用例
66+### 5. `BUG-012` 当前没有 stale `inFlight` 自动清扫机制
67+
68+- 这轮修复已经把“永久挂起”收口成“明确超时失败”。
69+- 但如果未来真的出现长期不恢复的 lease 泄漏,同一 `target` 的后续请求会稳定超时失败,而不是自动自愈。
70+- 如果要进一步增强,应单开任务补 stale `inFlight` 清扫机制。
71+
72+### 6. `ws_reconnect` 的自动化验证还不是 Firefox 真实 reconnect 生命周期验收
73
74-- 这次 `25be868` 的启动恢复改动已经在 `controller.js` 中存在。
75-- 但仓库里当前没有直接覆盖“浏览器启动后自动后台 `tab_restore`”的独立测试文件。
76-- 因此这部分目前更适合写成:
77- - “代码已落地”
78- - “建议继续补专项自动化或真实 Firefox 手工验收”
79+- 当前 smoke 覆盖的是 conductor 侧 `action_result` 语义透传。
80+- 真实 Firefox 扩展运行环境里的 reconnect 生命周期本身,仍依赖后续 `hello` / 状态同步来体现“真正重连完成”。
81+- 这一层现有设计未扩改,因此仍建议在真实 Firefox 环境里补手工 smoke。
82
83 ## 建议给 Claude 重点复核的点
84
85-1. 复核 `../plugins/baa-firefox/controller.js:3090-3100`、`3366-3455`、`4401-4425`
86- - 目标:确认启动自动恢复和根页 adopt 逻辑确实能把 desired/actual 调和起来
87-2. 复核 `../plugins/baa-firefox/controller.js:3384-3389` 与 `3218-3256`
88- - 目标:确认 `BUG-014` 的判断是否准确
89-3. 复核 `../apps/conductor-daemon/src/local-api.ts:2747-2825`、`4328-4375`、`4717-4875`
90- - 目标:确认当前正式 browser 读写面和 action_result/stream/cancel 的外部合同描述是否准确
91-4. 复核 `../apps/conductor-daemon/src/browser-request-policy.ts:117-145`
92+1. 复核 `../apps/conductor-daemon/src/index.ts` 与 `../apps/conductor-daemon/src/index.test.js`
93+ - 目标:确认 `BUG-011` 的 body / stream 背压断连修复和测试范围准确
94+2. 复核 `../apps/conductor-daemon/src/browser-request-policy.ts` 与 `../apps/conductor-daemon/src/index.test.js`
95+ - 目标:确认 `BUG-012` 的 timeout / cleanup 已落地,以及“没有 stale `inFlight` 自动清扫”这一残余风险判断准确
96+3. 复核 `../plugins/baa-firefox/controller.js` 与 `../tests/browser/browser-control-e2e-smoke.test.mjs`
97+ - 目标:确认 `BUG-014` 的 deferred 结果语义已落地,且当前自动化验证只覆盖 conductor 侧语义透传
98+4. 复核 `../apps/conductor-daemon/src/local-api.ts`
99+ - 目标:确认当前正式 browser 读写面和 action_result / stream / cancel 的外部合同描述仍准确
100+5. 复核 `../apps/conductor-daemon/src/browser-request-policy.ts:117-145`
101 - 目标:确认默认风控参数已经实装,而不是仅在文档里声明
102-5. 复核 `../apps/conductor-daemon/src/index.test.js` 和 `../tests/browser/browser-control-e2e-smoke.test.mjs`
103- - 目标:确认本文写的“自动化已验证范围”没有夸大
104
105 ## 适合对外同步的简版结论
106
107 如果只写一段给外部协作者看,可以用下面这版:
108
109-> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude browser relay 主链路。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化都已落地,并已通过 `conductor-daemon build`、`index.test.js`(25/25)和 browser-control e2e smoke(3/3)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、`BUG-011`~`BUG-014` 仍待修、正式 relay 仍只有 Claude 接通、以及风控运行态仍是进程内内存态。
110+> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude browser relay 主链路。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化都已落地;`BUG-011`、`BUG-012`、`BUG-014` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(31/31)和 browser-control e2e smoke(3/3)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、open backlog 还剩 `BUG-013` / `BUG-017`、正式 relay 仍只有 Claude 接通、风控运行态仍是进程内内存态,以及 `BUG-012` 还没有 stale `inFlight` 自动清扫机制。
1@@ -89,6 +89,7 @@ export interface BrowserRequestPolicyLease {
2 }
3
4 export interface BrowserRequestPolicyControllerOptions {
5+ clearTimeoutImpl?: (handle: TimeoutHandle) => void;
6 config?: Partial<BrowserRequestPolicyConfig>;
7 now?: () => number;
8 random?: () => number;
9@@ -143,6 +144,7 @@ const DEFAULT_BROWSER_REQUEST_POLICY: BrowserRequestPolicyConfig = {
10 openTimeoutMs: 10_000
11 }
12 };
13+const BROWSER_REQUEST_WAITER_TIMEOUT_MS = 120_000;
14
15 function clonePolicyConfig(input: BrowserRequestPolicyConfig): BrowserRequestPolicyConfig {
16 return {
17@@ -274,6 +276,7 @@ export class BrowserRequestPolicyError extends Error {
18 }
19
20 export class BrowserRequestPolicyController {
21+ private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
22 private readonly config: BrowserRequestPolicyConfig;
23 private readonly now: () => number;
24 private readonly platforms = new Map<string, BrowserRequestPlatformState>();
25@@ -285,6 +288,7 @@ export class BrowserRequestPolicyController {
26 this.config = clonePolicyConfig(
27 mergePolicyConfig(DEFAULT_BROWSER_REQUEST_POLICY, options.config ?? {})
28 );
29+ this.clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => globalThis.clearTimeout(handle));
30 this.now = options.now ?? (() => Date.now());
31 this.random = options.random ?? (() => Math.random());
32 this.setTimeoutImpl = options.setTimeoutImpl ?? ((handler, timeoutMs) => globalThis.setTimeout(handler, timeoutMs));
33@@ -343,7 +347,7 @@ export class BrowserRequestPolicyController {
34 const normalizedTarget = this.normalizeTarget(target);
35 const requestedAt = this.now();
36 const targetState = this.getTargetState(normalizedTarget);
37- await this.acquireTargetSlot(targetState);
38+ await this.acquireTargetSlot(normalizedTarget, targetState);
39 let admission: BrowserRequestAdmission | null = null;
40
41 try {
42@@ -412,7 +416,46 @@ export class BrowserRequestPolicyController {
43 return created;
44 }
45
46- private async acquireTargetSlot(state: BrowserRequestTargetState): Promise<void> {
47+ private async waitForWaiter(
48+ waiters: Array<() => void>,
49+ buildError: () => BrowserRequestPolicyError
50+ ): Promise<void> {
51+ await new Promise<void>((resolve, reject) => {
52+ let settled = false;
53+ let timer: TimeoutHandle | null = null;
54+ const onReady = () => {
55+ if (settled) {
56+ return;
57+ }
58+
59+ settled = true;
60+ if (timer != null) {
61+ this.clearTimeoutImpl(timer);
62+ }
63+ resolve();
64+ };
65+
66+ timer = this.setTimeoutImpl(() => {
67+ if (settled) {
68+ return;
69+ }
70+
71+ settled = true;
72+ const index = waiters.indexOf(onReady);
73+ if (index !== -1) {
74+ waiters.splice(index, 1);
75+ }
76+ reject(buildError());
77+ }, BROWSER_REQUEST_WAITER_TIMEOUT_MS);
78+
79+ waiters.push(onReady);
80+ });
81+ }
82+
83+ private async acquireTargetSlot(
84+ target: BrowserRequestTarget,
85+ state: BrowserRequestTargetState
86+ ): Promise<void> {
87 if (
88 state.inFlight < this.config.concurrency.maxInFlightPerClientPlatform
89 && state.waiters.length === 0
90@@ -421,9 +464,20 @@ export class BrowserRequestPolicyController {
91 return;
92 }
93
94- await new Promise<void>((resolve) => {
95- state.waiters.push(resolve);
96- });
97+ await this.waitForWaiter(
98+ state.waiters,
99+ () =>
100+ new BrowserRequestPolicyError(
101+ "waiter_timeout",
102+ `Timed out waiting for a browser request slot for ${target.platform} on client "${target.clientId}".`,
103+ {
104+ client_id: target.clientId,
105+ platform: target.platform,
106+ timeout_ms: BROWSER_REQUEST_WAITER_TIMEOUT_MS,
107+ wait_scope: "target_slot"
108+ }
109+ )
110+ );
111 }
112
113 private releaseTargetSlot(state: BrowserRequestTargetState): void {
114@@ -437,15 +491,29 @@ export class BrowserRequestPolicyController {
115 state.inFlight = Math.max(0, state.inFlight - 1);
116 }
117
118- private async acquirePlatformAdmission(state: BrowserRequestPlatformState): Promise<void> {
119+ private async acquirePlatformAdmission(
120+ target: BrowserRequestTarget,
121+ state: BrowserRequestPlatformState
122+ ): Promise<void> {
123 if (!state.busy) {
124 state.busy = true;
125 return;
126 }
127
128- await new Promise<void>((resolve) => {
129- state.waiters.push(resolve);
130- });
131+ await this.waitForWaiter(
132+ state.waiters,
133+ () =>
134+ new BrowserRequestPolicyError(
135+ "waiter_timeout",
136+ `Timed out waiting for browser request admission on platform "${target.platform}".`,
137+ {
138+ client_id: target.clientId,
139+ platform: target.platform,
140+ timeout_ms: BROWSER_REQUEST_WAITER_TIMEOUT_MS,
141+ wait_scope: "platform_admission"
142+ }
143+ )
144+ );
145 }
146
147 private releasePlatformAdmission(state: BrowserRequestPlatformState): void {
148@@ -466,7 +534,7 @@ export class BrowserRequestPolicyController {
149 requestedAt: number
150 ): Promise<BrowserRequestAdmission> {
151 const platformState = this.getPlatformState(target.platform);
152- await this.acquirePlatformAdmission(platformState);
153+ await this.acquirePlatformAdmission(target, platformState);
154 let backoffDelayMs = 0;
155 let jitterDelayMs = 0;
156 let rateLimitDelayMs = 0;
+390,
-1
1@@ -1,4 +1,5 @@
2 import assert from "node:assert/strict";
3+import { EventEmitter } from "node:events";
4 import { createServer } from "node:http";
5 import { mkdtempSync, rmSync } from "node:fs";
6 import { createConnection } from "node:net";
7@@ -8,13 +9,17 @@ import test from "node:test";
8
9 import { ConductorLocalControlPlane } from "../dist/local-control-plane.js";
10 import {
11+ BrowserRequestPolicyController,
12 ConductorDaemon,
13 ConductorRuntime,
14 createFetchControlApiClient,
15 handleConductorHttpRequest,
16- parseConductorCliRequest
17+ parseConductorCliRequest,
18+ writeHttpResponse
19 } from "../dist/index.js";
20
21+const BROWSER_REQUEST_WAITER_TIMEOUT_MS = 120_000;
22+
23 function createLeaseResult({
24 holderId,
25 holderHost = "mini",
26@@ -467,6 +472,81 @@ function parseJsonBody(response) {
27 return JSON.parse(response.body);
28 }
29
30+function createManualTimerScheduler() {
31+ let now = 0;
32+ let nextId = 1;
33+ const timers = new Map();
34+
35+ const pickNextTimerId = (deadline) => {
36+ let selectedId = null;
37+ let selectedDueAt = Number.POSITIVE_INFINITY;
38+
39+ for (const [timerId, timer] of timers.entries()) {
40+ if (timer.dueAt > deadline) {
41+ continue;
42+ }
43+
44+ if (
45+ selectedId === null
46+ || timer.dueAt < selectedDueAt
47+ || (timer.dueAt === selectedDueAt && timerId < selectedId)
48+ ) {
49+ selectedId = timerId;
50+ selectedDueAt = timer.dueAt;
51+ }
52+ }
53+
54+ return selectedId;
55+ };
56+
57+ return {
58+ advanceBy(durationMs) {
59+ const deadline = now + Math.max(0, durationMs);
60+
61+ while (true) {
62+ const timerId = pickNextTimerId(deadline);
63+ if (timerId == null) {
64+ break;
65+ }
66+
67+ const timer = timers.get(timerId);
68+ timers.delete(timerId);
69+ now = timer.dueAt;
70+ timer.handler();
71+ }
72+
73+ now = deadline;
74+ },
75+ clearTimeout(timerId) {
76+ timers.delete(timerId);
77+ },
78+ now: () => now,
79+ setTimeout(handler, timeoutMs) {
80+ const timerId = nextId++;
81+ timers.set(timerId, {
82+ dueAt: now + Math.max(0, timeoutMs),
83+ handler
84+ });
85+ return timerId;
86+ }
87+ };
88+}
89+
90+async function flushAsyncWork() {
91+ await Promise.resolve();
92+ await Promise.resolve();
93+}
94+
95+function getPolicyPlatformSnapshot(policy, platform) {
96+ return policy.getSnapshot().platforms.find((entry) => entry.platform === platform) ?? null;
97+}
98+
99+function getPolicyTargetSnapshot(policy, clientId, platform) {
100+ return policy.getSnapshot().targets.find(
101+ (entry) => entry.clientId === clientId && entry.platform === platform
102+ ) ?? null;
103+}
104+
105 async function readResponseBodyText(response) {
106 let text = response.body;
107
108@@ -1183,6 +1263,55 @@ async function waitForCondition(assertion, timeoutMs = 2_000, intervalMs = 50) {
109 throw lastError ?? new Error("timed out waiting for condition");
110 }
111
112+class MockWritableResponse extends EventEmitter {
113+ constructor(onWrite) {
114+ super();
115+ this.destroyed = false;
116+ this.endCalls = [];
117+ this.headers = {};
118+ this.onWrite = onWrite;
119+ this.statusCode = 200;
120+ this.writableEnded = false;
121+ this.writeCalls = [];
122+ }
123+
124+ setHeader(name, value) {
125+ this.headers[name] = value;
126+ }
127+
128+ write(chunk) {
129+ this.writeCalls.push(chunk);
130+ return this.onWrite(chunk, this.writeCalls.length, this);
131+ }
132+
133+ end(chunk) {
134+ if (chunk !== undefined) {
135+ this.endCalls.push(chunk);
136+ }
137+
138+ this.writableEnded = true;
139+ }
140+}
141+
142+async function assertSettlesWithin(promise, timeoutMs = 250) {
143+ let timeoutId = null;
144+
145+ try {
146+ return await Promise.race([
147+ promise,
148+ new Promise((_, reject) => {
149+ timeoutId = setTimeout(() => {
150+ reject(new Error(`expected promise to settle within ${timeoutMs}ms`));
151+ }, timeoutMs);
152+ })
153+ ]);
154+ } finally {
155+ if (timeoutId != null) {
156+ clearTimeout(timeoutId);
157+ }
158+ }
159+}
160+
161 async function fetchJson(url, init) {
162 const response = await fetch(url, init);
163 const text = await response.text();
164@@ -1228,6 +1357,103 @@ async function connectFirefoxBridgeClient(wsUrl, clientId) {
165 };
166 }
167
168+test("writeHttpResponse stops waiting for body backpressure when the client closes", async () => {
169+ const response = new MockWritableResponse((_chunk, writeCount, writableResponse) => {
170+ assert.equal(writeCount, 1);
171+ queueMicrotask(() => {
172+ writableResponse.destroyed = true;
173+ writableResponse.emit("close");
174+ });
175+
176+ return false;
177+ });
178+
179+ await assertSettlesWithin(
180+ writeHttpResponse(response, {
181+ body: "preface",
182+ headers: {
183+ "content-type": "text/plain; charset=utf-8"
184+ },
185+ status: 200,
186+ streamBody: (async function* () {
187+ yield "tail";
188+ })()
189+ })
190+ );
191+
192+ assert.deepEqual(response.writeCalls, ["preface"]);
193+ assert.equal(response.writableEnded, false);
194+ assert.equal(response.listenerCount("drain"), 0);
195+ assert.equal(response.listenerCount("close"), 0);
196+ assert.equal(response.listenerCount("error"), 0);
197+});
198+
199+test("writeHttpResponse stops waiting for stream backpressure when the client closes", async () => {
200+ const response = new MockWritableResponse((_chunk, writeCount, writableResponse) => {
201+ assert.equal(writeCount, 1);
202+ queueMicrotask(() => {
203+ writableResponse.destroyed = true;
204+ writableResponse.emit("close");
205+ });
206+
207+ return false;
208+ });
209+
210+ await assertSettlesWithin(
211+ writeHttpResponse(response, {
212+ body: "",
213+ headers: {
214+ "content-type": "text/event-stream; charset=utf-8"
215+ },
216+ status: 200,
217+ streamBody: (async function* () {
218+ yield "chunk-1";
219+ yield "chunk-2";
220+ })()
221+ })
222+ );
223+
224+ assert.deepEqual(response.writeCalls, ["chunk-1"]);
225+ assert.equal(response.writableEnded, false);
226+ assert.equal(response.listenerCount("drain"), 0);
227+ assert.equal(response.listenerCount("close"), 0);
228+ assert.equal(response.listenerCount("error"), 0);
229+});
230+
231+test("writeHttpResponse continues streaming after drain and finishes the response", async () => {
232+ const response = new MockWritableResponse((_chunk, writeCount, writableResponse) => {
233+ if (writeCount === 1) {
234+ queueMicrotask(() => {
235+ writableResponse.emit("drain");
236+ });
237+
238+ return false;
239+ }
240+
241+ return true;
242+ });
243+
244+ await assertSettlesWithin(
245+ writeHttpResponse(response, {
246+ body: "preface",
247+ headers: {
248+ "content-type": "text/event-stream; charset=utf-8"
249+ },
250+ status: 200,
251+ streamBody: (async function* () {
252+ yield "chunk-1";
253+ yield "chunk-2";
254+ })()
255+ })
256+ );
257+
258+ assert.deepEqual(response.writeCalls, ["preface", "chunk-1", "chunk-2"]);
259+ assert.equal(response.writableEnded, true);
260+ assert.equal(response.listenerCount("drain"), 0);
261+ assert.equal(response.listenerCount("close"), 0);
262+ assert.equal(response.listenerCount("error"), 0);
263+});
264+
265 async function withRuntimeFixture(callback) {
266 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-runtime-fixture-"));
267 const runtime = new ConductorRuntime(
268@@ -2421,6 +2647,169 @@ test("handleConductorHttpRequest returns a clear 503 for Claude browser actions
269 assert.equal(payload.error, "browser_bridge_unavailable");
270 });
271
272+test("BrowserRequestPolicyController times out target slot waiters and removes them from the queue", async () => {
273+ const scheduler = createManualTimerScheduler();
274+ const policy = new BrowserRequestPolicyController({
275+ clearTimeoutImpl: scheduler.clearTimeout,
276+ config: {
277+ jitter: {
278+ maxMs: 0,
279+ minMs: 0,
280+ muMs: 0,
281+ sigmaMs: 0
282+ }
283+ },
284+ now: scheduler.now,
285+ setTimeoutImpl: scheduler.setTimeout
286+ });
287+
288+ const leakedLease = await policy.beginRequest({
289+ clientId: "firefox-claude",
290+ platform: "claude"
291+ }, "request-leaked");
292+ const waitingLeasePromise = policy.beginRequest({
293+ clientId: "firefox-claude",
294+ platform: "claude"
295+ }, "request-blocked");
296+
297+ await flushAsyncWork();
298+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude", "claude")?.waiting, 1);
299+
300+ scheduler.advanceBy(BROWSER_REQUEST_WAITER_TIMEOUT_MS);
301+
302+ await assert.rejects(waitingLeasePromise, (error) => {
303+ assert.equal(error.code, "waiter_timeout");
304+ assert.equal(error.details.wait_scope, "target_slot");
305+ assert.equal(error.details.client_id, "firefox-claude");
306+ assert.equal(error.details.platform, "claude");
307+ return true;
308+ });
309+
310+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude", "claude")?.waiting, 0);
311+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude", "claude")?.inFlight, 1);
312+
313+ leakedLease.complete({
314+ status: "cancelled"
315+ });
316+
317+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude", "claude")?.inFlight, 0);
318+});
319+
320+test("BrowserRequestPolicyController times out platform admission waiters and releases their target slot", async () => {
321+ const scheduler = createManualTimerScheduler();
322+ const policy = new BrowserRequestPolicyController({
323+ clearTimeoutImpl: scheduler.clearTimeout,
324+ config: {
325+ jitter: {
326+ maxMs: 0,
327+ minMs: 0,
328+ muMs: 0,
329+ sigmaMs: 0
330+ },
331+ rateLimit: {
332+ requestsPerMinutePerPlatform: 0,
333+ windowMs: BROWSER_REQUEST_WAITER_TIMEOUT_MS + 60_000
334+ }
335+ },
336+ now: scheduler.now,
337+ setTimeoutImpl: scheduler.setTimeout
338+ });
339+
340+ const blockedAdmissionPromise = policy.beginRequest({
341+ clientId: "firefox-claude-a",
342+ platform: "claude"
343+ }, "request-admit-blocker");
344+
345+ await flushAsyncWork();
346+
347+ const waitingLeasePromise = policy.beginRequest({
348+ clientId: "firefox-claude-b",
349+ platform: "claude"
350+ }, "request-admit-blocked");
351+
352+ await flushAsyncWork();
353+ assert.equal(getPolicyPlatformSnapshot(policy, "claude")?.waiting, 1);
354+
355+ scheduler.advanceBy(BROWSER_REQUEST_WAITER_TIMEOUT_MS);
356+
357+ await assert.rejects(waitingLeasePromise, (error) => {
358+ assert.equal(error.code, "waiter_timeout");
359+ assert.equal(error.details.wait_scope, "platform_admission");
360+ assert.equal(error.details.client_id, "firefox-claude-b");
361+ assert.equal(error.details.platform, "claude");
362+ return true;
363+ });
364+
365+ assert.equal(getPolicyPlatformSnapshot(policy, "claude")?.waiting, 0);
366+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude-b", "claude")?.inFlight, 0);
367+
368+ scheduler.advanceBy(60_000);
369+ const delayedLease = await blockedAdmissionPromise;
370+ delayedLease.complete({
371+ status: "cancelled"
372+ });
373+
374+ assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude-a", "claude")?.inFlight, 0);
375+});
376+
377+test("handleConductorHttpRequest returns a clear 503 when a leaked browser request lease blocks the target slot", async () => {
378+ const { repository, sharedToken, snapshot } = await createLocalApiFixture();
379+ const browser = createBrowserBridgeStub();
380+ const scheduler = createManualTimerScheduler();
381+ const policy = new BrowserRequestPolicyController({
382+ clearTimeoutImpl: scheduler.clearTimeout,
383+ config: {
384+ jitter: {
385+ maxMs: 0,
386+ minMs: 0,
387+ muMs: 0,
388+ sigmaMs: 0
389+ }
390+ },
391+ now: scheduler.now,
392+ setTimeoutImpl: scheduler.setTimeout
393+ });
394+
395+ const leakedLease = await policy.beginRequest({
396+ clientId: "firefox-claude",
397+ platform: "claude"
398+ }, "request-leaked");
399+
400+ const responsePromise = handleConductorHttpRequest(
401+ {
402+ body: JSON.stringify({
403+ platform: "claude",
404+ prompt: "blocked browser request"
405+ }),
406+ method: "POST",
407+ path: "/v1/browser/request"
408+ },
409+ {
410+ ...browser.context,
411+ browserRequestPolicy: policy,
412+ repository,
413+ sharedToken,
414+ snapshotLoader: () => snapshot
415+ }
416+ );
417+
418+ await flushAsyncWork();
419+ scheduler.advanceBy(BROWSER_REQUEST_WAITER_TIMEOUT_MS);
420+
421+ const response = await responsePromise;
422+ assert.equal(response.status, 503);
423+ const payload = parseJsonBody(response);
424+ assert.equal(payload.ok, false);
425+ assert.equal(payload.error, "browser_risk_limited");
426+ assert.match(payload.message, /could not schedule browser request/u);
427+ assert.equal(payload.details.error_code, "waiter_timeout");
428+ assert.equal(payload.details.wait_scope, "target_slot");
429+
430+ leakedLease.complete({
431+ status: "cancelled"
432+ });
433+});
434+
435 test(
436 "handleConductorHttpRequest normalizes exec failures that are blocked by macOS TCC preflight",
437 { concurrency: false },
+58,
-8
1@@ -533,12 +533,58 @@ function resolveLocalApiListenConfig(localApiBase: string): LocalApiListenConfig
2 };
3 }
4
5-async function writeHttpResponse(
6+async function awaitWritableDrainOrClose(
7+ writableResponse: {
8+ destroyed?: boolean;
9+ off?(event: string, listener: () => void): unknown;
10+ on?(event: string, listener: () => void): unknown;
11+ }
12+): Promise<boolean> {
13+ if (writableResponse.destroyed === true) {
14+ return false;
15+ }
16+
17+ if (typeof writableResponse.on !== "function") {
18+ return true;
19+ }
20+
21+ return new Promise<boolean>((resolve) => {
22+ const cleanup = () => {
23+ writableResponse.off?.("drain", onDrain);
24+ writableResponse.off?.("close", onClose);
25+ writableResponse.off?.("error", onError);
26+ };
27+ const onDrain = () => {
28+ cleanup();
29+ resolve(true);
30+ };
31+ const onClose = () => {
32+ cleanup();
33+ resolve(false);
34+ };
35+ const onError = () => {
36+ cleanup();
37+ resolve(false);
38+ };
39+
40+ writableResponse.on?.("drain", onDrain);
41+ writableResponse.on?.("close", onClose);
42+ writableResponse.on?.("error", onError);
43+
44+ if (writableResponse.destroyed === true) {
45+ cleanup();
46+ resolve(false);
47+ }
48+ });
49+}
50+
51+export async function writeHttpResponse(
52 response: ServerResponse<IncomingMessage>,
53 payload: ConductorHttpResponse
54 ): Promise<void> {
55 const writableResponse = response as ServerResponse<IncomingMessage> & {
56 destroyed?: boolean;
57+ off?(event: string, listener: () => void): unknown;
58 on?(event: string, listener: () => void): unknown;
59 writableEnded?: boolean;
60 write?(chunk: string): boolean;
61@@ -556,9 +602,11 @@ async function writeHttpResponse(
62
63 if (payload.body !== "" && typeof writableResponse.write === "function") {
64 if (!writableResponse.write(payload.body)) {
65- await new Promise<void>((resolve) => {
66- writableResponse.on?.("drain", resolve);
67- });
68+ const canContinue = await awaitWritableDrainOrClose(writableResponse);
69+
70+ if (!canContinue) {
71+ return;
72+ }
73 }
74 }
75
76@@ -572,13 +620,15 @@ async function writeHttpResponse(
77 }
78
79 if (!writableResponse.write(chunk)) {
80- await new Promise<void>((resolve) => {
81- writableResponse.on?.("drain", resolve);
82- });
83+ const canContinue = await awaitWritableDrainOrClose(writableResponse);
84+
85+ if (!canContinue) {
86+ break;
87+ }
88 }
89 }
90
91- if (writableResponse.writableEnded !== true) {
92+ if (writableResponse.destroyed !== true && writableResponse.writableEnded !== true) {
93 response.end();
94 }
95 }
1@@ -1,6 +1,23 @@
2 # BUG-011: writeHttpResponse drain handler 可永久挂起
3
4-## 现象
5+## 状态
6+
7+- 已修复(2026-03-27,代码核对 + 自动化验证)
8+
9+## 当前代码结论
10+
11+- `apps/conductor-daemon/src/index.ts` 已新增 `awaitWritableDrainOrClose(...)`
12+- `payload.body` 写入和 `streamBody` 循环写入两条背压路径都改成同时处理 `drain` / `close` / `error`
13+- 当连接在背压期间关闭时,`writeHttpResponse()` 会停止继续写入并清理 listener,不再永久 pending
14+- `apps/conductor-daemon/src/index.test.js` 已补 3 条专项测试:
15+ - body 背压后 close
16+ - stream 背压后 close
17+ - drain 触发后继续写完并正常结束
18+- 验证结果:
19+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build` 通过
20+ - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js` 通过(`31/31`)
21+
22+## 修复前现象
23
24 当慢客户端或代理中间人在 HTTP 响应写入过程中断连时,conductor-daemon 的请求处理协程永远不返回,表现为请求计数只增不减、内存缓慢增长。
25
26@@ -9,7 +26,7 @@
27 - 预期:连接断开后协程应正常退出
28 - 复现条件:需要客户端在 write 返回 false(背压)后、drain 事件触发前断开连接
29
30-## 触发路径
31+## 修复前触发路径
32
33 ```text
34 HTTP client -> conductor local API (any endpoint with large body or SSE stream)
35@@ -21,7 +38,7 @@ HTTP client -> conductor local API (any endpoint with large body or SSE stream)
36 -> coroutine hangs forever
37 ```
38
39-## 根因
40+## 修复前根因
41
42 `writeHttpResponse()` 中有两处 drain 等待逻辑:
43
44@@ -31,7 +48,7 @@ HTTP client -> conductor local API (any endpoint with large body or SSE stream)
45 两处都只监听了 `drain` 事件,没有监听 `close` / `error` 事件。Node.js HTTP response 在客户端断连后不会再触发 drain,只会触发 close。
46
47 ```typescript
48-// 当前代码
49+// 修复前代码
50 if (!writableResponse.write(payload.body)) {
51 await new Promise<void>((resolve) => {
52 writableResponse.on?.("drain", resolve);
53@@ -55,14 +72,14 @@ if (!writableResponse.write(payload.body)) {
54
55 注意:在正常网络条件下不容易触发,需要 body 大到触发背压。SSE stream 路径更容易触发。
56
57-## 当前影响
58+## 修复前影响
59
60 - 主流程不受影响(需要特定条件触发)
61 - SSE stream(browser proxy 返回 claude.ai 的 SSE 流)是最可能的触发场景
62 - 在 nginx 反代后面运行时,nginx 主动关闭超时连接可触发
63 - 长期运行会导致协程泄漏和内存缓慢增长
64
65-## 修复建议
66+## 修复方案(已落地)
67
68 ### 方案 A(推荐)
69
70@@ -107,6 +124,5 @@ Medium-High
71
72 ## 备注
73
74-- 当前未在线上明确观察到,但 nginx 502 超时场景下可能已经触发过
75-- streamBody 循环中的同一模式也需要同步修复
76-- 修复后建议加单元测试:mock 一个 write 返回 false 后立即 destroy 的 response 对象
77+- 当前文档保留为问题归档;实际代码已完成修复
78+- 两处 drain 等待点都已同步修复,没有只修一处
1@@ -1,6 +1,29 @@
2 # BUG-012: browser-request-policy waiter 无超时,slot 泄漏导致目标永久死锁
3
4-## 现象
5+## 状态
6+
7+- 已修复(2026-03-27,代码核对 + 自动化验证)
8+
9+## 当前代码结论
10+
11+- `apps/conductor-daemon/src/browser-request-policy.ts` 已新增 `BROWSER_REQUEST_WAITER_TIMEOUT_MS = 120_000`
12+- target slot 和 platform admission 两类 waiter 都改为经由统一的 `waitForWaiter(...)` 等待,并在超时后从队列里移除自己
13+- 超时会返回 `BrowserRequestPolicyError("waiter_timeout")`,并带 `wait_scope`、`client_id`、`platform`、`timeout_ms`
14+- `apps/conductor-daemon/src/index.test.js` 已补 3 条专项测试:
15+ - target slot waiter timeout
16+ - platform admission waiter timeout
17+ - HTTP `503 browser_risk_limited` 返回路径
18+- 验证结果:
19+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build` 通过
20+ - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js` 通过(`31/31`)
21+
22+## 剩余风险
23+
24+- 这次修复解决的是“永久挂起”,不是“自动回收泄漏 slot”
25+- 如果某个 lease 持续泄漏且一直不恢复,同一 `target` 的后续请求会稳定超时失败,而不是自动自愈
26+- 如需继续增强,可后续新增 stale `inFlight` 清扫机制;这不属于本轮修复范围
27+
28+## 修复前现象
29
30 当某个 browser request 的 policy lease 没有被正常 `complete()` 时(如 Firefox 断连、conductor 内部异常),对同一 `(clientId, platform)` 目标的所有后续请求永久挂起,表现为 `/v1/browser/request` 或 `/v1/browser/claude/send` 无响应。
31
32@@ -9,7 +32,7 @@
33 - 预期:超时后应该返回错误或自动释放 slot
34 - 是否稳定复现:需要特定异常路径触发,但一旦触发即永久生效
35
36-## 触发路径
37+## 修复前触发路径
38
39 ```text
40 POST /v1/browser/request (或 /v1/browser/claude/send)
41@@ -24,7 +47,7 @@ POST /v1/browser/request (或 /v1/browser/claude/send)
42 -> 后续请求进入 acquireTargetSlot() -> waiters.push(resolve) -> 永不 resolve
43 ```
44
45-## 根因
46+## 修复前根因
47
48 两个独立问题共同导致:
49
50@@ -86,14 +109,14 @@ curl -s https://conductor.makefile.so/v1/browser/claude/send \
51
52 注意:需要在精确的时间窗口内断开 Firefox 才能触发。但当前 `maxInFlightPerClientPlatform = 1`,意味着只需一次泄漏就会死锁。
53
54-## 当前影响
55+## 修复前影响
56
57 - 当前是潜在风险,尚未在线上明确观察到
58 - `maxInFlightPerClientPlatform = 1` 意味着一次泄漏 = 该 target 永久不可用
59 - task scheduler 上线后,并发调用 browser request 的概率增大,触发风险提高
60 - 重启 conductor-daemon 可恢复(内存状态清空)
61
62-## 修复建议
63+## 修复方案(已落地)
64
65 ### 方案 A(推荐:多层防御)
66
67@@ -129,6 +152,5 @@ Medium
68
69 ## 备注
70
71-- 可以通过 `/v1/browser/status` 返回的 policy snapshot 中的 `targets[].inFlight` 和 `targets[].waiting` 观测是否已经发生泄漏
72-- 临时绕过方式:重启 conductor-daemon
73-- 修复后建议补测试:mock 一个 beginRequest 后不调 complete 的场景,验证超时后 waiter 被正确释放
74+- 当前文档保留为问题归档;实际代码已完成 waiter timeout 与清理
75+- 本轮没有实现 stale `inFlight` 自动清扫;这是后续增强项,不是未修复状态
1@@ -1,6 +1,26 @@
2 # BUG-014: ws_reconnect 报 completed=true 但实际还未重连
3
4-## 现象
5+## 状态
6+
7+- 已修复(2026-03-27,代码核对 + 自动化验证)
8+
9+## 当前代码结论
10+
11+- `plugins/baa-firefox/controller.js` 的 `ws_reconnect` 分支现在会返回 `deferred: true`、`scheduled: true`
12+- `sendPluginActionResult(...)` 的公共发送路径会把 deferred 结果写成 `completed: false`
13+- `tests/browser/browser-control-e2e-smoke.test.mjs` 已覆盖:
14+ - `plugin_status.completed === true`
15+ - `ws_reconnect.completed === false`
16+- 验证结果:
17+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build` 通过
18+ - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs` 通过(`3/3`)
19+
20+## 剩余风险
21+
22+- 当前自动化验证覆盖的是 conductor 侧端到端语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期本身
23+- 真实浏览器里“真正重连完成”仍依赖后续 `hello` / 状态同步来体现;这一层现有设计未扩改
24+
25+## 修复前现象
26
27 执行 `ws_reconnect` 动作后,conductor 立即收到 `action_result` 且 `completed: true, failed: false`,但此时 WS 实际尚未断开,更没有重连。真正的断开和重连在 80ms 后的 `setTimeout` 回调中才发生。
28
29@@ -9,7 +29,7 @@
30 - 预期:要么等重连完成再报 `completed: true`,要么报 `completed: false` 表示异步完成
31 - 复现条件:任何 `ws_reconnect` 调用
32
33-## 触发路径
34+## 修复前触发路径
35
36 ```text
37 POST /v1/browser/actions {"action":"ws_reconnect"}
38@@ -22,11 +42,11 @@ POST /v1/browser/actions {"action":"ws_reconnect"}
39 → 80ms 后 WS 才真正断开并重连
40 ```
41
42-## 根因
43+## 修复前根因
44
45 `ws_reconnect` 用 `setTimeout` 延迟执行断开和重连,但 `runPluginManagementAction` 是同步返回的,不等 setTimeout 回调。上层 `.then()` 在函数返回后立刻发送 `completed: true` 的 action_result。
46
47-## 影响
48+## 修复前影响
49
50 - 语义不准确:conductor 侧依赖 `completed` 判断动作是否执行完毕
51 - 如果后续有自动化逻辑在收到 completed=true 后立即发送请求,可能在旧连接上发送
52@@ -36,7 +56,7 @@ POST /v1/browser/actions {"action":"ws_reconnect"}
53
54 Low-Medium
55
56-## 建议修复方案
57+## 修复方案(已落地)
58
59 方案 A(推荐):让 `runPluginManagementAction` 的 ws_reconnect 分支返回一个带标记的结果,上层据此发送 `completed: false`,让 conductor 知道这是异步完成的动作。重连成功后 conductor 通过 `hello` 消息自然感知。
60
+18,
-42
1@@ -1,56 +1,32 @@
2 # FIX-BUG-011: writeHttpResponse drain handler 永久挂起
3
4-## 关联 Bug
5-
6-BUG-011-writeHttpResponse-drain-handler-hangs.md
7-
8-## 目标
9+## 执行状态
10
11-修复 `writeHttpResponse()` 中 drain 等待无 close 监听导致协程挂死的问题。
12+- 已完成(2026-03-27,代码 + 自动化验证已落地)
13
14-## 修改文件
15-
16-`apps/conductor-daemon/src/index.ts` — `writeHttpResponse()` 函数
17-
18-## 修改方案
19+## 关联 Bug
20
21-在每个 `await new Promise(resolve => writableResponse.on("drain", resolve))` 处,同时监听 `close` 事件,任一触发即 resolve 并清理另一个 listener。
22+BUG-011-writeHttpResponse-drain-handler-hangs.md
23
24-有两处需要修改:
25+## 实际修改文件
26
27-### 第一处:初始 body 写入的 drain 等待
28+- `apps/conductor-daemon/src/index.ts`
29+- `apps/conductor-daemon/src/index.test.js`
30
31-将:
32-```typescript
33-if (!writableResponse.write(payload.body)) {
34- await new Promise<void>((resolve) => {
35- writableResponse.on?.("drain", resolve);
36- });
37-}
38-```
39+## 实际修改
40
41-改为:
42-```typescript
43-if (!writableResponse.write(payload.body)) {
44- await new Promise<void>((resolve) => {
45- const onDrain = () => { cleanup(); resolve(); };
46- const onClose = () => { cleanup(); resolve(); };
47- const cleanup = () => {
48- writableResponse.off?.("drain", onDrain);
49- writableResponse.off?.("close", onClose);
50- };
51- writableResponse.on?.("drain", onDrain);
52- writableResponse.on?.("close", onClose);
53- });
54-}
55-```
56+- 在 `index.ts` 中新增 `awaitWritableDrainOrClose(...)`,统一处理 `drain` / `close` / `error`
57+- `writeHttpResponse()` 的 body 写入和 `streamBody` 写入都改为复用该 helper
58+- 当连接已销毁时,不再继续写入,也不会在销毁连接上调用 `response.end()`
59+- 在 `index.test.js` 中新增 3 条专项测试,覆盖 body 背压关闭、stream 背压关闭和正常 drain 恢复写入
60
61-### 第二处:streamBody 循环内的 drain 等待
62+## 验证结果
63
64-同样模式,替换 streamBody `for await` 循环中的 drain 等待。
65+1. `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build` 通过
66+2. `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js` 通过(`31/31`)
67
68 ## 验收标准
69
70-1. `pnpm typecheck` 通过
71-2. `pnpm test` 通过
72-3. 不引入新的 lint 或类型警告
73+1. 背压后客户端断开时,请求处理不会永久挂起
74+2. `payload.body` 和 `streamBody` 两条路径都已覆盖
75+3. 相关自动化测试已通过
+20,
-61
1@@ -1,75 +1,34 @@
2 # FIX-BUG-012: browser-request-policy waiter 死锁
3
4-## 关联 Bug
5-
6-BUG-012-browser-request-policy-waiter-deadlock.md
7-
8-## 目标
9-
10-防止 `acquireTargetSlot` 和 `acquirePlatformAdmission` 中的 waiter Promise 永久挂起。
11-
12-## 修改文件
13+## 执行状态
14
15-`apps/conductor-daemon/src/browser-request-policy.ts`
16+- 已完成(2026-03-27,代码 + 自动化验证已落地)
17
18-## 修改方案
19-
20-### 方案 A(推荐):给 waiter 加超时
21-
22-在 `acquireTargetSlot` 中:
23-
24-```typescript
25-private async acquireTargetSlot(state: BrowserRequestTargetState): Promise<void> {
26- if (
27- state.inFlight < this.config.concurrency.maxInFlightPerClientPlatform
28- && state.waiters.length === 0
29- ) {
30- state.inFlight += 1;
31- return;
32- }
33+## 关联 Bug
34
35- await new Promise<void>((resolve, reject) => {
36- const timer = this.setTimeoutImpl(() => {
37- const index = state.waiters.indexOf(onReady);
38- if (index !== -1) state.waiters.splice(index, 1);
39- reject(new BrowserRequestPolicyError(
40- "waiter_timeout",
41- "Timed out waiting for a browser request slot.",
42- { timeoutMs: WAITER_TIMEOUT_MS }
43- ));
44- }, WAITER_TIMEOUT_MS);
45- const onReady = () => {
46- clearTimeout(timer);
47- resolve();
48- };
49- state.waiters.push(onReady);
50- });
51-}
52-```
53+BUG-012-browser-request-policy-waiter-deadlock.md
54
55-超时常量建议 `WAITER_TIMEOUT_MS = 120_000`(2 分钟)。
56+## 实际修改文件
57
58-同样模式应用到 `acquirePlatformAdmission`。
59+- `apps/conductor-daemon/src/browser-request-policy.ts`
60+- `apps/conductor-daemon/src/index.test.js`
61
62-### 方案 B(防御性补充):releaseTargetSlot 防御检查
63+## 实际修改
64
65-在 `releaseTargetSlot` 中增加防御:
66+- 在 `BrowserRequestPolicyControllerOptions` 中补了 `clearTimeoutImpl`,方便测试环境精确控制 timer
67+- 新增 `BROWSER_REQUEST_WAITER_TIMEOUT_MS = 120_000`
68+- 新增统一的 `waitForWaiter(...)`,负责 waiter 入队、超时、移除和 reject
69+- `acquireTargetSlot(...)` 与 `acquirePlatformAdmission(...)` 都改为在超时后抛出 `waiter_timeout`
70+- 在 `index.test.js` 中新增 target slot timeout、platform admission timeout 和 HTTP 503 返回路径测试
71
72-```typescript
73-private releaseTargetSlot(state: BrowserRequestTargetState): void {
74- const waiter = state.waiters.shift();
75- if (waiter != null) {
76- waiter();
77- return;
78- }
79- state.inFlight = Math.max(0, state.inFlight - 1);
80-}
81-```
82+## 非目标 / 剩余风险
83
84-当前已有此逻辑,确认 `Math.max(0, ...)` 防御到位。
85+- 本轮没有实现 stale `inFlight` 自动回收
86+- 如果未来真的出现长期不恢复的 lease 泄漏,同一 `target` 的请求会超时失败,而不是自动自愈
87+- 如需继续增强,应单开任务补 stale `inFlight` 清扫机制
88
89 ## 验收标准
90
91-1. `pnpm typecheck` 通过
92-2. `pnpm test` 通过
93-3. 新增单元测试:模拟 lease 未 complete 的场景,验证后续请求在超时后返回错误而非永久挂起
94+1. `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build` 通过
95+2. `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js` 通过(`31/31`)
96+3. waiter 泄漏场景现在会返回明确错误,而不是永久挂起
+18,
-46
1@@ -1,60 +1,32 @@
2 # FIX-BUG-014: ws_reconnect 过早报 completed=true
3
4-## 关联 Bug
5-
6-BUG-014-ws-reconnect-premature-completed.md
7-
8-## 目标
9-
10-让 ws_reconnect 的 action_result 语义正确:在重连尚未完成时不报 completed=true。
11+## 执行状态
12
13-## 修改文件
14+- 已完成(2026-03-27,代码 + 自动化验证已落地)
15
16-`plugins/baa-firefox/controller.js`
17-
18-## 修改方案
19-
20-在 `runPluginManagementAction` 的 ws_reconnect case 中,给返回结果增加一个标记(例如 `deferred: true`),让上层的 `sendPluginActionResult` 调用时将 `completed` 设为 `false`。
21+## 关联 Bug
22
23-具体改动:
24+BUG-014-ws-reconnect-premature-completed.md
25
26-### 1. runPluginManagementAction ws_reconnect 分支
27+## 实际修改文件
28
29-在 break 前,给 results 加一个带 `deferred` 标记的条目,或者在返回对象上加 `deferred: true`:
30+- `plugins/baa-firefox/controller.js`
31+- `tests/browser/browser-control-e2e-smoke.test.mjs`
32
33-```javascript
34-case "ws_reconnect":
35- addLog("info", "正在重连本地 WS", false);
36- setTimeout(() => {
37- closeWsConnection();
38- connectWs({ silentWhenDisabled: true });
39- }, 80);
40- // 标记为延迟完成
41- return {
42- action: methodName,
43- platform: trimToNull(options.platform),
44- results: normalizedResults,
45- snapshot: buildPluginStatusPayload(),
46- deferred: true
47- };
48-```
49+## 实际修改
50
51-### 2. connectWs 中 action_result 发送逻辑
52+- `runPluginManagementAction("ws_reconnect")` 现在会返回带 `deferred: true` 的结构化结果
53+- `connectWs(...)` 中统一发送 `action_result` 时,会把 deferred 结果转成 `completed: false`
54+- browser control smoke 已补 `ws_reconnect` 断言,确认 conductor 收到的是 `completed: false`
55
56-在 `.then()` 中检查 deferred 标记:
57+## 非目标 / 剩余风险
58
59-```javascript
60-}).then((result) => {
61- sendPluginActionResult(result, {
62- ...,
63- completed: result.deferred !== true
64- });
65-})
66-```
67+- 本轮验证的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期
68+- 真实重连完成仍通过后续 `hello` / 状态同步自然体现;当前设计保持不变
69
70 ## 验收标准
71
72-1. 执行 ws_reconnect 后 action_result 的 `completed` 为 `false`
73-2. conductor 侧能正确识别异步完成的动作
74-3. 其他 action(tab_open、plugin_status 等)仍然报 `completed: true`
75-4. 不引入新的 lint 或类型警告
76+1. `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build` 通过
77+2. `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs` 通过(`3/3`)
78+3. `ws_reconnect` 现在会返回 `completed: false`
79+4. `plugin_status` 等同步动作仍保持 `completed: true`
+19,
-6
1@@ -12,23 +12,36 @@
2 1. `BUG-008` — 已随 `BUG-010` 一并修复
3 2. `BUG-009` — 已修复
4 3. `BUG-010` — 已修复
5-4. `BUG-015` — 按当前代码核对,SSE 流式回传主链路已落地,不再作为“插件缺少 SSE 实现”的 open bug 保留
6-5. `BUG-016` — 按当前代码核对,自定义 headers 已进入 bridge -> plugin -> page fetch 链路,不再作为“headers 完全未透传”的 open bug 保留
7+4. `BUG-011` — 已修复;`writeHttpResponse()` 在 body / stream 背压路径下都会等待 `drain` / `close` / `error`,不再永久挂起
8+5. `BUG-012` — 已修复;browser request policy waiter 现在会超时退出并返回明确错误,不再永久挂起
9+6. `BUG-014` — 已修复;`ws_reconnect` 现改为 deferred 结果,`action_result.completed` 不再提前为 `true`
10+7. `BUG-015` — 按当前代码核对,SSE 流式回传主链路已落地,不再作为“插件缺少 SSE 实现”的 open bug 保留
11+8. `BUG-016` — 按当前代码核对,自定义 headers 已进入 bridge -> plugin -> page fetch 链路,不再作为“headers 完全未透传”的 open bug 保留
12
13 ## 待修复
14
15 | # | 文件 | 问题 | 严重度 | 修复卡 |
16 |---|---|---|---|---|
17-| BUG-011 | `BUG-011-*.md` | writeHttpResponse drain handler 永久挂起 | Medium-High | FIX-BUG-011.md |
18-| BUG-012 | `BUG-012-*.md` | browser-request-policy waiter 死锁 | Medium | FIX-BUG-012.md |
19 | BUG-013 | `BUG-013-*.md` | stream session timer 未清除 | Low | FIX-BUG-013.md |
20-| BUG-014 | `BUG-014-*.md` | ws_reconnect 提前报 completed=true | Low-Medium | FIX-BUG-014.md |
21 | BUG-017 | `BUG-017-*.md` | buffered 模式 SSE 端点返回原始文本 | Low | FIX-BUG-017.md |
22
23-修复优先级:BUG-011 > BUG-012 > BUG-014 > BUG-017 > BUG-013
24+修复优先级:BUG-017 > BUG-013
25
26 ## 当前代码核对结论(2026-03-27)
27
28+- `BUG-011` 已修复:
29+ - `apps/conductor-daemon/src/index.ts` 已新增 `awaitWritableDrainOrClose(...)`
30+ - `payload.body` 和 `streamBody` 两条背压等待路径都会同时处理 `drain` / `close` / `error`
31+ - `apps/conductor-daemon/src/index.test.js` 已补 body close、stream close 和 drain 继续写入 3 条测试
32+- `BUG-012` 已修复:
33+ - `apps/conductor-daemon/src/browser-request-policy.ts` 已为 target slot / platform admission waiter 增加超时与队列清理
34+ - `apps/conductor-daemon/src/index.test.js` 已覆盖 target_slot timeout、platform_admission timeout 和 HTTP 503 返回路径
35+ - 剩余风险:这轮修复解决的是“永久挂起”,不是“自动回收泄漏 slot”;如果某个 lease 持续泄漏且不恢复,同一 `target` 的后续请求会稳定超时失败,而不是自动自愈。如需继续增强,可后续补 stale `inFlight` 清扫机制
36+- `BUG-014` 已修复:
37+ - `plugins/baa-firefox/controller.js` 的 `ws_reconnect` 现在会返回 `deferred: true`
38+ - `sendPluginActionResult(...)` 会把 deferred 结果发送成 `completed: false`
39+ - `tests/browser/browser-control-e2e-smoke.test.mjs` 已覆盖 `plugin_status.completed === true` 和 `ws_reconnect.completed === false`
40+ - 剩余风险:当前自动化验证覆盖的是 conductor 侧端到端语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步,现有设计未扩改
41 - `BUG-015` 的“插件侧缺少 SSE 实现”结论与当前代码不一致:
42 - `plugins/baa-firefox/controller.js` 已有 `response_mode === "sse"` 分支
43 - `plugins/baa-firefox/page-interceptor.js` 已实现 `streamProxyResponse(...)`
+12,
-6
1@@ -8,15 +8,16 @@
2
3 - 浏览器控制主链路收口基线:`main@07895cd`
4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
5+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-014` 修复
6 - 任务文档已统一收口到 `tasks/`
7 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
8-- `T-S001` 到 `T-S025` 已经完成
9+- `T-S001` 到 `T-S025` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
10
11 ## 当前状态分类
12
13-- `已完成`:`T-S001` 到 `T-S025`
14+- `已完成`:`T-S001` 到 `T-S025`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
15 - `当前 TODO`:无高优先级主线任务
16-- `待处理缺陷`:`BUG-011` 到 `BUG-014`(见 `bugs/README.md`)
17+- `待处理缺陷`:`BUG-013`、`BUG-017`(见 `bugs/README.md`)
18 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
19
20 当前新的主需求文档:
21@@ -66,6 +67,9 @@
22 2. `T-S024`:已完成,README / docs / smoke / 状态视图已同步到正式口径
23 3. `T-S025`:已完成,`shell_runtime`、结构化 `action_result` 和控制读面已接入主线
24 4. `2026-03-27` 跟进修复:Firefox 插件管理页启动、浏览器重开或扩展重载后,会自动恢复之前明确启用过、但当前缺失的 shell tab;平台根页也会被收进受管理 shell 集合
25+5. `2026-03-27` 跟进修复:`BUG-011` 已修复,`writeHttpResponse()` 的 body / stream 背压断连不再永久挂起
26+6. `2026-03-27` 跟进修复:`BUG-012` 已修复,browser request policy waiter 现在会超时退出并返回明确错误
27+7. `2026-03-27` 跟进修复:`BUG-014` 已修复,`ws_reconnect` 的 `action_result.completed` 不再提前为 `true`
28
29 当前策略:
30
31@@ -81,8 +85,8 @@
32
33 ## 当前缺陷 backlog
34
35-- 当前待修:`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`
36-- 对应修复卡:`FIX-BUG-011.md`、`FIX-BUG-012.md`、`FIX-BUG-013.md`、`FIX-BUG-014.md`
37+- 当前待修:`BUG-013`、`BUG-017`
38+- 对应修复卡:`FIX-BUG-013.md`、`FIX-BUG-017.md`
39 - 当前没有 bug fix 正在主线开发中;如需继续推进,直接从 `bugs/` 目录对应 fix 卡开工
40
41 ## 低优先级 TODO
42@@ -152,6 +156,8 @@
43 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
44 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
45 - 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,没有接入通用 request / SSE 合同
46-- `BUG-011` 到 `BUG-014` 当前都还没有进入“已修复”状态;其中 `BUG-014` 直接影响 `ws_reconnect` 的 `action_result.completed` 语义
47+- 当前 open bug backlog 已缩到 `BUG-013` 和 `BUG-017`
48+- `BUG-012` 这轮修复解决的是“永久挂起”,不是“自动回收泄漏 slot”;如果未来出现长期不恢复的 lease 泄漏,同一 `target` 的请求会超时失败,而不是自动自愈
49+- `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
50 - 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> tab_restore -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前唯一环境型残余风险
51 - 当前多个源码文件已超过常规体量,但拆分重构已明确延后到浏览器桥接主线收口之后再做
+9,
-1
1@@ -3387,7 +3387,14 @@ async function runPluginManagementAction(action, options = {}) {
2 closeWsConnection();
3 connectWs({ silentWhenDisabled: true });
4 }, 80);
5- break;
6+ return {
7+ action: methodName,
8+ platform: trimToNull(options.platform),
9+ deferred: true,
10+ results,
11+ scheduled: true,
12+ snapshot: buildPluginStatusPayload()
13+ };
14 case "controller_reload":
15 setControllerRuntimeState({
16 ready: false,
17@@ -3943,6 +3950,7 @@ function connectWs(options = {}) {
18 sendPluginActionResult(result, {
19 action: pluginAction.action,
20 commandType: pluginAction.commandType,
21+ completed: result?.deferred !== true,
22 platform: pluginAction.platform,
23 requestId: pluginAction.requestId
24 });
+17,
-0
1@@ -14,6 +14,10 @@
2
3 - `fix/bug-011-write-http-response`
4
5+## 当前状态
6+
7+- 已完成(2026-03-27,当前代码已修复并通过自动化验证)
8+
9 ## 目标
10
11 修复 `writeHttpResponse()` 在背压后客户端提前断开时可能永久挂起的问题,并补上可复现的自动化测试。
12@@ -92,6 +96,19 @@
13 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
14 - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
15
16+## 完成记录
17+
18+- 实际修改文件:
19+ - `/Users/george/code/baa-conductor/apps/conductor-daemon/src/index.ts`
20+ - `/Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
21+- 实际结果:
22+ - 新增 `awaitWritableDrainOrClose(...)`
23+ - `writeHttpResponse()` 的 body / stream 背压等待都改成同时处理 `drain` / `close` / `error`
24+ - 已补 3 条专项测试,覆盖 body close、stream close、drain 后继续写入
25+- 实际验证:
26+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
27+ - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`(`31/31`)
28+
29 ## 交付要求
30
31 完成后请说明:
+21,
-0
1@@ -14,6 +14,10 @@
2
3 - `fix/bug-012-browser-request-policy`
4
5+## 当前状态
6+
7+- 已完成(2026-03-27,当前代码已修复并通过自动化验证)
8+
9 ## 目标
10
11 避免 `browser-request-policy` 在 lease 未正常 `complete()` 时让后续请求永久挂起,并补上对应测试。
12@@ -99,6 +103,23 @@
13 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
14 - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
15
16+## 完成记录
17+
18+- 实际修改文件:
19+ - `/Users/george/code/baa-conductor/apps/conductor-daemon/src/browser-request-policy.ts`
20+ - `/Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
21+- 实际结果:
22+ - target slot / platform admission waiter 现在都会在 120s 后超时退出并清理队列
23+ - timeout 会返回 `waiter_timeout`,并带 `wait_scope`
24+ - 已补 target slot、platform admission 和 HTTP 503 3 条专项测试
25+- 实际验证:
26+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
27+ - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`(`31/31`)
28+- 剩余风险:
29+ - 本轮修复解决的是“永久挂起”,不是“自动回收泄漏 slot”
30+ - 如果未来出现长期不恢复的 lease 泄漏,同一 `target` 的请求会超时失败,而不是自动自愈
31+ - 如需继续增强,应单开任务补 stale `inFlight` 清扫机制
32+
33 ## 交付要求
34
35 完成后请说明:
+20,
-0
1@@ -14,6 +14,10 @@
2
3 - `fix/bug-014-ws-reconnect-completed`
4
5+## 当前状态
6+
7+- 已完成(2026-03-27,当前代码已修复并通过自动化验证)
8+
9 ## 目标
10
11 让 `ws_reconnect` 的 `action_result.completed` 语义与真实执行时序一致,不再在真正重连前就回 `completed: true`。
12@@ -94,6 +98,22 @@
13 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
14 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
15
16+## 完成记录
17+
18+- 实际修改文件:
19+ - `/Users/george/code/baa-conductor/plugins/baa-firefox/controller.js`
20+ - `/Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
21+- 实际结果:
22+ - `ws_reconnect` 现在会返回 `deferred: true`
23+ - `action_result` 发送路径会把 deferred 结果转成 `completed: false`
24+ - smoke 已覆盖 `plugin_status.completed === true` 与 `ws_reconnect.completed === false`
25+- 实际验证:
26+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
27+ - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`(`3/3`)
28+- 剩余风险:
29+ - 当前自动化验证覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期
30+ - 真实浏览器里的“已重连完成”仍依赖后续 `hello` / 状态同步
31+
32 ## 交付要求
33
34 完成后请说明:
+2,
-2
1@@ -28,7 +28,7 @@
2 - `2026-03-26`:自动化验证已通过,包括 `apps/conductor-daemon/src/index.test.js` 和 `tests/browser/browser-control-e2e-smoke.test.mjs`。
3 - `2026-03-26`:真实 Firefox 手工 smoke 在当前机器上受阻;未发现 `Firefox.app`(已检查 `/Applications` 与 `~/Applications`),因此无法在本环境完成“真实 Firefox 手工验收”。
4 - `2026-03-27`:后续代码跟进已补上“插件管理页启动 / 浏览器重开 / 扩展重载后自动恢复 desired shell tabs”,并支持把平台根页收进受管理 shell 集合。
5-- `2026-03-27`:`BUG-014` 已登记;当前 `ws_reconnect` 仍会在真正断开 / 重连前回报 `completed=true`,需要后续单独修复。
6+- `2026-03-27`:后续缺陷修复已补上 `ws_reconnect` 的 deferred 结果语义;当前 `action_result.completed` 不再在真正重连前提前为 `true`。
7
8 ## 建议分支名
9
10@@ -176,7 +176,7 @@
11 - `2026-03-27`:提交 `25be868` 已补上启动时的受管 shell tab 自动恢复逻辑,收口了“插件重启后 desired 仍在、actual 丢失但没有自动调和”的空窗。
12 - `2026-03-27`:同一轮跟进里,`verify-mini.sh` 的 wrapper 调用也改成数组参数拼装,降低空参数场景下的脚本调用风险。
13 - `2026-03-27`:当前没有新增手工 smoke 结果;环境阻塞仍然是本机缺少可启动的 `Firefox.app`。
14-- `2026-03-27`:当前仍需后续单独处理 `BUG-014`,不应把它误判为已随本轮启动恢复修复一并关闭。
15+- `2026-03-27`:后续缺陷任务已修复 `BUG-014`,并补上 `ws_reconnect.completed === false` 的 smoke 断言;剩余风险不再是 completed 语义,而是真实 Firefox reconnect 生命周期仍依赖手工验收。
16
17 ## 自动化验证
18
+16,
-4
1@@ -11,12 +11,13 @@
2 - 当前任务卡都放在本目录
3 - 浏览器控制主链路收口基线:`main@07895cd`
4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
5+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-014` 修复
6
7 ## 状态分类
8
9-- `已完成`:`T-S001` 到 `T-S025`
10+- `已完成`:`T-S001` 到 `T-S025`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
11 - `当前 TODO`:无高优先级主线任务
12-- `待处理缺陷`:`BUG-011` 到 `BUG-014`(见 `../bugs/README.md`)
13+- `待处理缺陷`:`BUG-013`、`BUG-017`(见 `../bugs/README.md`)
14 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
15
16 当前新的主需求文档:
17@@ -54,6 +55,12 @@
18 24. [`T-S024.md`](./T-S024.md):回写正式文档、补 browser smoke 并同步主线状态
19 25. [`T-S025.md`](./T-S025.md):收口插件管理闭环与真实 Firefox 验收
20
21+最近完成的缺陷任务:
22+
23+26. [`T-BUG-011.md`](./T-BUG-011.md):修复 `writeHttpResponse()` 在背压断连下的永久挂起,并补专项测试
24+27. [`T-BUG-012.md`](./T-BUG-012.md):修复 `browser-request-policy` waiter 永久挂起,并补专项测试
25+28. [`T-BUG-014.md`](./T-BUG-014.md):修正 `ws_reconnect` 的 `completed` 语义,并补 smoke 断言
26+
27 当前主线已经额外收口:
28
29 - 根级 `pnpm smoke`,覆盖 repo 内可自举的 runtime compatibility / legacy absence / codexd e2e / browser-control e2e smoke
30@@ -67,7 +74,7 @@
31 ## 当前活动任务
32
33 - 当前没有高优先级活动任务卡;如需继续推进,直接新开后续任务
34-- 当前可直接执行的缺陷修复卡位于 `../bugs/`:`FIX-BUG-011.md`、`FIX-BUG-012.md`、`FIX-BUG-013.md`、`FIX-BUG-014.md`
35+- 当前可直接执行的缺陷修复卡位于 `../bugs/`:`FIX-BUG-013.md`、`FIX-BUG-017.md`
36
37 ## 当前主线收口情况
38
39@@ -88,6 +95,9 @@
40 - `2026-03-27`:Firefox 插件管理页启动、浏览器重开或扩展重载后,会自动恢复之前明确启用过、但当前缺失的 shell tab
41 - `2026-03-27`:如果用户手工打开 Claude `new`、ChatGPT 根页或 Gemini `app` 这类平台根页,插件会把它们纳入受管理 shell 集合
42 - `2026-03-27`:`verify-mini.sh` 的 wrapper 调用已收口为数组参数组装,避免空参数场景下的命令拼接问题
43+- `2026-03-27`:`BUG-011` 已修复,`writeHttpResponse()` 的 body / stream 背压断连不再永久挂起
44+- `2026-03-27`:`BUG-012` 已修复,browser request policy waiter 现在会超时退出并返回明确错误
45+- `2026-03-27`:`BUG-014` 已修复,`ws_reconnect` 的 `action_result.completed` 现在会正确返回 `false`
46
47 建议并行关系:
48
49@@ -102,7 +112,9 @@
50
51 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
52 - 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,没有接入通用 request / SSE 合同
53-- `BUG-011` 到 `BUG-014` 当前都还是待修状态;其中 `BUG-014` 直接影响 `ws_reconnect` 的 `completed` 语义
54+- 当前 open bug backlog 已缩到 `BUG-013` 和 `BUG-017`
55+- `BUG-012` 这轮修复解决的是“永久挂起”,不是“自动回收泄漏 slot”;如果未来出现长期不恢复的 lease 泄漏,同一 `target` 的请求会超时失败,而不是自动自愈
56+- `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
57 - runtime smoke 仍依赖仓库根已有 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮功能回归
58 - 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> `tab_restore` -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前唯一残余风险
59
1@@ -449,8 +449,35 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
2 const pluginStatusResult = await pluginStatusPromise;
3 assert.equal(pluginStatusResult.response.status, 200);
4 assert.equal(pluginStatusResult.payload.data.action, "plugin_status");
5+ assert.equal(pluginStatusResult.payload.data.completed, true);
6 assert.equal(pluginStatusResult.payload.data.result.platform_count, 1);
7
8+ const wsReconnectPromise = fetchJson(`${baseUrl}/v1/browser/actions`, {
9+ method: "POST",
10+ headers: {
11+ "content-type": "application/json"
12+ },
13+ body: JSON.stringify({
14+ action: "ws_reconnect",
15+ client_id: "firefox-browser-control-smoke"
16+ })
17+ });
18+
19+ const wsReconnectMessage = await client.queue.next(
20+ (message) => message.type === "ws_reconnect"
21+ );
22+ assert.equal(wsReconnectMessage.type, "ws_reconnect");
23+ sendPluginActionResult(client.socket, {
24+ action: "ws_reconnect",
25+ completed: false,
26+ requestId: wsReconnectMessage.requestId
27+ });
28+ const wsReconnectResult = await wsReconnectPromise;
29+ assert.equal(wsReconnectResult.response.status, 200);
30+ assert.equal(wsReconnectResult.payload.data.action, "ws_reconnect");
31+ assert.equal(wsReconnectResult.payload.data.completed, false);
32+ assert.equal(wsReconnectResult.payload.data.failed, false);
33+
34 const tabRestorePromise = fetchJson(`${baseUrl}/v1/browser/actions`, {
35 method: "POST",
36 headers: {