baa-conductor

git clone 

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
M PROGRESS/2026-03-27-current-code-progress.md
+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` 自动清扫机制。
M apps/conductor-daemon/src/browser-request-policy.ts
+78, -10
  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;
M apps/conductor-daemon/src/index.test.js
+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 },
M apps/conductor-daemon/src/index.ts
+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 }
M bugs/BUG-011-writeHttpResponse-drain-handler-hangs.md
+25, -9
 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 等待点都已同步修复,没有只修一处
M bugs/BUG-012-browser-request-policy-waiter-deadlock.md
+30, -8
 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` 自动清扫;这是后续增强项,不是未修复状态
M bugs/BUG-014-ws-reconnect-premature-completed.md
+25, -5
 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 
M bugs/FIX-BUG-011.md
+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. 相关自动化测试已通过
M bugs/FIX-BUG-012.md
+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 泄漏场景现在会返回明确错误,而不是永久挂起
M bugs/FIX-BUG-014.md
+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`
M bugs/README.md
+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(...)`
M plans/STATUS_SUMMARY.md
+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 - 当前多个源码文件已超过常规体量,但拆分重构已明确延后到浏览器桥接主线收口之后再做
M plugins/baa-firefox/controller.js
+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         });
M tasks/T-BUG-011.md
+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 完成后请说明:
M tasks/T-BUG-012.md
+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 完成后请说明:
M tasks/T-BUG-014.md
+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 完成后请说明:
M tasks/T-S025.md
+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 
M tasks/TASK_OVERVIEW.md
+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 
M tests/browser/browser-control-e2e-smoke.test.mjs
+27, -0
 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: {