baa-conductor

git clone 

commit
a2b1055
parent
3f8e273
author
codex@macbookpro
date
2026-03-27 14:55:34 +0800 CST
fix: resolve BUG-013 and BUG-017
10 files changed,  +336, -245
M PROGRESS/2026-03-27-current-code-progress.md
+14, -10
 1@@ -2,11 +2,11 @@
 2 
 3 ## 结论摘要
 4 
 5-- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-014` 修复。
 6+- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复。
 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 未完成、open backlog 还剩 `BUG-013` 和 `BUG-017`、风控状态仍是进程内内存态,以及 `BUG-012` 还没有 stale `inFlight` 自动清扫机制。
11+- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、`BUG-012` 还没有 stale `inFlight` 自动清扫机制,以及正式 browser relay 仍只有 Claude 接通。
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-  - 结果:`31/31` 通过
20+  - 结果:`32/32` 通过
21 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
22   - 结果:`3/3` 通过
23 
24@@ -172,7 +172,7 @@
25   - `../package.json:5-13`
26   - `../scripts/runtime/verify-mini.sh`
27 
28-### 10. `BUG-011`、`BUG-012`、`BUG-014` 已在当前代码中修复
29+### 10. `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 已在当前代码中修复
30 
31 - `BUG-011`
32   - `../apps/conductor-daemon/src/index.ts` 已新增 `awaitWritableDrainOrClose(...)`
33@@ -183,6 +183,12 @@
34 - `BUG-014`
35   - `../plugins/baa-firefox/controller.js` 的 `ws_reconnect` 已改为 deferred 结果
36   - `../tests/browser/browser-control-e2e-smoke.test.mjs` 已覆盖 `ws_reconnect.completed === false`
37+- `BUG-013`
38+  - `../apps/conductor-daemon/src/firefox-bridge.ts` 当前关闭路径已统一调用 `clearTimers()`
39+  - `../apps/conductor-daemon/src/index.test.js` 已覆盖 stream 结束后继续推进 timer 也不会再触发 timeout / cancel
40+- `BUG-017`
41+  - `../apps/conductor-daemon/src/local-api.ts` 已把 buffered 模式收到的 SSE 原始文本解析成结构化对象
42+  - `../apps/conductor-daemon/src/index.test.js` 已覆盖 buffered SSE 返回 `content_type` / `events` / `full_text`
43 
44 ## 当前未完成 / 待复核
45 
46@@ -191,12 +197,10 @@
47 - 代码和任务文档里都没有新增“真实 Firefox.app 上手动关 tab -> tab_restore -> WS 重连 -> 状态恢复”的实测结论。
48 - 当前自动化 smoke 是 bridge / relay / persistence 层面的模拟 E2E,不等于真实 Firefox 桌面手工验收。
49 
50-### 2. open backlog 现在只剩 `BUG-013` 和 `BUG-017`
51+### 2. open bug backlog 当前已清空
52 
53-- 当前不能写成“bug backlog 已清空”。
54-- 当前仍然 open 的是:
55-  - `BUG-013`:stream session timer 未清除
56-  - `BUG-017`:buffered 模式请求 SSE 端点时仍返回原始 SSE 文本
57+- 当前不能把“已修复 bug”误写成“所有残余风险都已消失”。
58+- 当前没有 open bug 卡,但仍保留若干非 bug 型残余风险和后续增强项。
59 
60 ### 3. 正式 browser relay 仍然只有 Claude 接通
61 
62@@ -240,4 +244,4 @@
63 
64 如果只写一段给外部协作者看,可以用下面这版:
65 
66-> 当前代码已经完成单节点 `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` 自动清扫机制。
67+> 当前代码已经完成单节点 `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-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(32/32)和 browser-control e2e smoke(3/3)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、正式 relay 仍只有 Claude 接通、风控运行态仍是进程内内存态,以及 `BUG-012` 还没有 stale `inFlight` 自动清扫机制。
M apps/conductor-daemon/src/index.test.js
+110, -0
  1@@ -8,6 +8,7 @@ import { join } from "node:path";
  2 import test from "node:test";
  3 
  4 import { ConductorLocalControlPlane } from "../dist/local-control-plane.js";
  5+import { FirefoxCommandBroker } from "../dist/firefox-bridge.js";
  6 import {
  7   BrowserRequestPolicyController,
  8   ConductorDaemon,
  9@@ -955,6 +956,19 @@ function createBrowserBridgeStub() {
 10             });
 11           }
 12 
 13+          if (input.path === "/api/stream-buffered-smoke") {
 14+            return buildApiResponse({
 15+              body: [
 16+                "event: completion",
 17+                'data: {"type":"completion","completion":"Hello "}',
 18+                "",
 19+                "event: completion",
 20+                'data: {"type":"completion","completion":"world"}',
 21+                ""
 22+              ].join("\n")
 23+            });
 24+          }
 25+
 26           throw new Error(`unexpected browser proxy path: ${input.path}`);
 27         },
 28         cancelApiRequest(input = {}) {
 29@@ -2139,6 +2153,31 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 30     );
 31     assert.equal(browserRequestPayload.data.policy.target_client_id, "firefox-claude");
 32 
 33+    const bufferedSseResponse = await handleConductorHttpRequest(
 34+      {
 35+        body: JSON.stringify({
 36+          platform: "claude",
 37+          method: "GET",
 38+          path: "/api/stream-buffered-smoke",
 39+          requestId: "browser-buffered-sse-123"
 40+        }),
 41+        method: "POST",
 42+        path: "/v1/browser/request"
 43+      },
 44+      localApiContext
 45+    );
 46+    assert.equal(bufferedSseResponse.status, 200);
 47+    const bufferedSsePayload = parseJsonBody(bufferedSseResponse);
 48+    assert.equal(bufferedSsePayload.data.request_mode, "api_request");
 49+    assert.equal(bufferedSsePayload.data.proxy.path, "/api/stream-buffered-smoke");
 50+    assert.equal(bufferedSsePayload.data.response.content_type, "text/event-stream");
 51+    assert.equal(bufferedSsePayload.data.response.events.length, 2);
 52+    assert.equal(bufferedSsePayload.data.response.events[0].event, "completion");
 53+    assert.equal(bufferedSsePayload.data.response.events[0].data.type, "completion");
 54+    assert.equal(bufferedSsePayload.data.response.events[0].data.completion, "Hello ");
 55+    assert.equal(bufferedSsePayload.data.response.events[1].data.completion, "world");
 56+    assert.equal(bufferedSsePayload.data.response.full_text, "Hello world");
 57+
 58     const browserStreamResponse = await handleConductorHttpRequest(
 59       {
 60         body: JSON.stringify({
 61@@ -2577,6 +2616,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 62       "apiRequest:GET:/api/organizations",
 63       "apiRequest:GET:/api/organizations/org-1/chat_conversations",
 64       "apiRequest:POST:/api/organizations/org-1/chat_conversations/conv-1/completion",
 65+      "apiRequest:GET:/api/stream-buffered-smoke",
 66       "apiRequest:GET:/api/organizations",
 67       "apiRequest:GET:/api/organizations/org-1/chat_conversations",
 68       "streamRequest:claude",
 69@@ -2752,6 +2792,76 @@ test("BrowserRequestPolicyController times out platform admission waiters and re
 70   assert.equal(getPolicyTargetSnapshot(policy, "firefox-claude-a", "claude")?.inFlight, 0);
 71 });
 72 
 73+test("FirefoxCommandBroker clears stream timers after the stream ends", async () => {
 74+  const scheduler = createManualTimerScheduler();
 75+  const sentMessages = [];
 76+  const client = {
 77+    clientId: "firefox-claude",
 78+    connectedAt: 0,
 79+    connectionId: "conn-firefox-claude",
 80+    lastMessageAt: 0,
 81+    sendJson(payload) {
 82+      sentMessages.push(payload);
 83+      return true;
 84+    }
 85+  };
 86+  const broker = new FirefoxCommandBroker({
 87+    clearTimeoutImpl: scheduler.clearTimeout,
 88+    now: scheduler.now,
 89+    resolveActiveClient: () => client,
 90+    resolveClientById: (clientId) => (clientId === client.clientId ? client : null),
 91+    setTimeoutImpl: scheduler.setTimeout
 92+  });
 93+
 94+  const stream = broker.openApiStream({
 95+    method: "GET",
 96+    path: "/api/organizations",
 97+    platform: "claude"
 98+  }, {
 99+    clientId: "firefox-claude",
100+    idleTimeoutMs: 1_000,
101+    openTimeoutMs: 1_000,
102+    requestId: "stream-finish-smoke",
103+    streamId: "stream-finish-smoke"
104+  });
105+
106+  assert.equal(sentMessages.length, 1);
107+  assert.equal(sentMessages[0].type, "api_request");
108+
109+  assert.equal(
110+    broker.handleStreamOpen("conn-firefox-claude", {
111+      id: "stream-finish-smoke",
112+      status: 200,
113+      streamId: "stream-finish-smoke"
114+    }),
115+    true
116+  );
117+  assert.equal(
118+    broker.handleStreamEnd("conn-firefox-claude", {
119+      id: "stream-finish-smoke",
120+      status: 200,
121+      streamId: "stream-finish-smoke"
122+    }),
123+    true
124+  );
125+
126+  const openEvent = await stream.next();
127+  assert.equal(openEvent.done, false);
128+  assert.equal(openEvent.value.type, "stream_open");
129+
130+  const endEvent = await stream.next();
131+  assert.equal(endEvent.done, false);
132+  assert.equal(endEvent.value.type, "stream_end");
133+
134+  const closedEvent = await stream.next();
135+  assert.equal(closedEvent.done, true);
136+  assert.equal(closedEvent.value, undefined);
137+
138+  scheduler.advanceBy(10_000);
139+
140+  assert.equal(sentMessages.length, 1);
141+});
142+
143 test("handleConductorHttpRequest returns a clear 503 when a leaked browser request lease blocks the target slot", async () => {
144   const { repository, sharedToken, snapshot } = await createLocalApiFixture();
145   const browser = createBrowserBridgeStub();
M apps/conductor-daemon/src/local-api.ts
+86, -0
  1@@ -1279,6 +1279,12 @@ function parseBrowserProxyBody(body: unknown): JsonValue | string | null {
  2     try {
  3       return JSON.parse(normalized) as JsonValue;
  4     } catch {
  5+      const parsedSseBody = parseBufferedSseProxyBody(body);
  6+
  7+      if (parsedSseBody != null) {
  8+        return parsedSseBody;
  9+      }
 10+
 11       return body;
 12     }
 13   }
 14@@ -1346,6 +1352,86 @@ function parseBrowserSseChunks(
 15   });
 16 }
 17 
 18+function isLikelyBufferedSseText(value: string): boolean {
 19+  const normalized = value.trim();
 20+
 21+  if (normalized === "") {
 22+    return false;
 23+  }
 24+
 25+  let prefixedLineCount = 0;
 26+
 27+  for (const rawLine of normalized.split(/\r?\n/gu)) {
 28+    const line = rawLine.trimStart();
 29+
 30+    if (line === "") {
 31+      continue;
 32+    }
 33+
 34+    if (line.startsWith(":")) {
 35+      continue;
 36+    }
 37+
 38+    if (/^(event|data|id|retry):/u.test(line)) {
 39+      prefixedLineCount += 1;
 40+      continue;
 41+    }
 42+
 43+    return false;
 44+  }
 45+
 46+  return prefixedLineCount > 0;
 47+}
 48+
 49+function extractBufferedSseTextFragments(value: JsonValue | null): string[] {
 50+  if (typeof value === "string") {
 51+    return value === "" ? [] : [value];
 52+  }
 53+
 54+  if (Array.isArray(value)) {
 55+    return value.flatMap((entry) => extractBufferedSseTextFragments(entry));
 56+  }
 57+
 58+  if (!isJsonObject(value)) {
 59+    return [];
 60+  }
 61+
 62+  const directKeys = ["text", "value", "content", "message", "markdown", "completion"];
 63+  const directValues = directKeys.flatMap((fieldName) =>
 64+    extractBufferedSseTextFragments(value[fieldName] as JsonValue)
 65+  );
 66+
 67+  if (directValues.length > 0) {
 68+    return directValues;
 69+  }
 70+
 71+  return Object.values(value).flatMap((entry) => extractBufferedSseTextFragments(entry as JsonValue));
 72+}
 73+
 74+function parseBufferedSseProxyBody(body: string): JsonObject | null {
 75+  if (!isLikelyBufferedSseText(body)) {
 76+    return null;
 77+  }
 78+
 79+  const events = parseBrowserSseChunks(body).map((entry) =>
 80+    compactJsonObject({
 81+      data: entry.data as JsonValue,
 82+      event: entry.event ?? undefined,
 83+      raw: entry.raw
 84+    })
 85+  );
 86+  const fullText = events
 87+    .flatMap((entry) => extractBufferedSseTextFragments((entry.data ?? null) as JsonValue | null))
 88+    .join("");
 89+
 90+  return compactJsonObject({
 91+    content_type: "text/event-stream",
 92+    events,
 93+    full_text: fullText.trim() === "" ? undefined : fullText,
 94+    raw: body
 95+  });
 96+}
 97+
 98 function createSseResponse(
 99   body: string,
100   streamBody: AsyncIterable<string> | null = null
M bugs/BUG-013-stream-session-timer-not-cleared.md
+33, -86
  1@@ -1,6 +1,19 @@
  2 # BUG-013: firefox-bridge stream session 关闭时未清除 openTimer/idleTimer
  3 
  4-## 现象
  5+## 状态
  6+
  7+- 已修复(2026-03-27,代码核对 + 自动化验证)
  8+
  9+## 当前代码结论
 10+
 11+- `apps/conductor-daemon/src/firefox-bridge.ts` 的 stream session 关闭路径当前会经由 `close()` 调用 `clearTimers()`
 12+- `clearTimers()` 会统一清理 `openTimer` 和 `idleTimer`
 13+- `apps/conductor-daemon/src/index.test.js` 已补 `FirefoxCommandBroker clears stream timers after the stream ends` 回归测试
 14+- 验证结果:
 15+  - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build` 通过
 16+  - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js` 通过(`32/32`)
 17+
 18+## 修复前现象
 19 
 20 stream session 关闭后,先前设置的 `openTimer` 和 `idleTimer` 仍在运行。timer 触发时由于 `closed` flag 保护不会产生功能异常,但 session 对象在 timer 到期前无法被 GC。
 21 
 22@@ -9,104 +22,38 @@ stream session 关闭后,先前设置的 `openTimer` 和 `idleTimer` 仍在运
 23 - 预期:session 关闭时应立即清除所有 timer
 24 - 是否稳定复现:每次 stream session 正常结束或异常关闭都会触发
 25 
 26-## 触发路径
 27+## 修复前触发路径
 28 
 29 ```text
 30 POST /v1/browser/request (responseMode: "sse")
 31 -> 创建 FirefoxBridgeApiStreamSession
 32 -> resetOpenTimer() — 设置 openTimeoutMs timer
 33--> 收到 stream_open → resetIdleTimer() — 设置 idleTimeoutMs timer
 34--> 收到 stream_end → finishWithEvent()
 35--> closed = true, onClose() 被调用, waiters 被清空
 36--> 但 idleTimer 仍在运行
 37--> idleTimer 触发 → fail() → markError() → closed 检查返回 false
 38--> session 对象直到 timer 触发才能被 GC
 39-```
 40-
 41-## 根因
 42-
 43-`finishWithEvent()` 方法中只做了三件事:
 44-
 45-1. 设置 `closed = true`
 46-2. 入队最后一个事件或 resolve waiter
 47-3. 调用 `onClose(requestId)`
 48-
 49-没有清除 `openTimer` 和 `idleTimer`。
 50-
 51-相关代码位于 `FirefoxBridgeApiStreamSession` 的私有方法中:
 52-
 53-- `resetOpenTimer()` 设置 `this.openTimer = setTimeoutImpl(...)`
 54-- `resetIdleTimer()` 设置 `this.idleTimer = setTimeoutImpl(...)`
 55-- `finishWithEvent()` 没有调用 `clearTimeoutImpl()` 清除这两个 timer
 56-
 57-## 复现步骤
 58-
 59-```bash
 60-TOKEN="<BAA_SHARED_TOKEN>"
 61-
 62-# 发起一个 SSE 模式的 browser request
 63-curl -s https://conductor.makefile.so/v1/browser/request \
 64-  -X POST -H "Content-Type: application/json" \
 65-  -H "Authorization: Bearer $TOKEN" \
 66-  -d '{
 67-    "platform": "claude",
 68-    "path": "/api/organizations",
 69-    "method": "GET",
 70-    "responseMode": "sse"
 71-  }'
 72-
 73-# 请求正常完成后,session 的 idleTimer(默认 30s)仍在运行
 74-# 30s 后 timer 触发,尝试 fail() 但被 closed 保护
 75-# 这 30s 内 session 对象无法被 GC
 76+-> 收到 stream_open -> resetIdleTimer() — 设置 idleTimeoutMs timer
 77+-> 收到 stream_end -> finishWithEvent()
 78+-> session 关闭,但 timer 仍保留到超时
 79 ```
 80 
 81-## 当前影响
 82-
 83-- 不影响功能正确性(`closed` flag 做了保护)
 84-- 每个 stream session 关闭后最多多占用 30s(idleTimeoutMs)内存
 85-- 对于 openTimeout(10s),如果 stream 在 open 之前就关闭了,会多占 10s
 86-- 在高频 SSE 请求场景下,内存占用会有可观察的波动
 87-
 88-## 修复建议
 89-
 90-### 方案 A(推荐)
 91-
 92-在 `finishWithEvent()` 方法的开头清除所有 timer:
 93-
 94-```typescript
 95-private finishWithEvent(event: FirefoxBridgeStreamEvent): boolean {
 96-  // 清除所有待触发的 timer
 97-  if (this.openTimer != null) {
 98-    this.clearTimeoutImpl(this.openTimer);
 99-    this.openTimer = null;
100-  }
101-  if (this.idleTimer != null) {
102-    this.clearTimeoutImpl(this.idleTimer);
103-    this.idleTimer = null;
104-  }
105-
106-  // 原有逻辑
107-  this.closed = true;
108-  // ...
109-}
110-```
111+## 修复前根因
112 
113-同时在 `cancel()` 方法中也加同样的清理(cancel 最终会调 fail -> markError -> finishWithEvent,所以只要 finishWithEvent 里做了就够,但显式清理更安全)。
114+这张卡最初来自代码审查时的旧判断:认为 `finishWithEvent()` 没有同步清理 timer。
115 
116-## 严重程度
117+按当前代码核对,真正的关闭路径是:
118 
119-Low
120+- `finishWithEvent()` -> `close()`
121+- `close()` -> `clearTimers()`
122 
123-- 有 `closed` flag 保护,不会导致功能错误
124-- 影响仅为短暂内存延迟释放(最多 30s)
125-- 不会累积(timer 触发后对象即可被 GC)
126+所以这张 bug 在当前代码里已经不成立;本轮补的是自动化回归测试,防止后续回归。
127 
128-## 发现时间
129+## 修复方案(已落地)
130 
131-2026-03-26 by Claude (code review)
132+- 不再额外修改 `firefox-bridge.ts` 逻辑
133+- 在 `apps/conductor-daemon/src/index.test.js` 中新增 broker 级回归测试:
134+  - 创建 stream session
135+  - 收到 `stream_open` / `stream_end`
136+  - 再推进 timer
137+  - 验证不会再发送 timeout / cancel
138 
139 ## 备注
140 
141-- 修复非常简单,4 行代码
142-- 建议修复后补单元测试:创建 stream session → markEnd() → 验证 openTimer 和 idleTimer 都被清除
143-- 可以和 BUG-011、BUG-012 一起修,同一个 PR
144+- 当前文档保留为问题归档;实际代码已具备 timer 清理
145+- 这次新增的是自动化保护,而不是新的运行时代码分支
M bugs/BUG-017-buffered-sse-raw-text.md
+32, -41
  1@@ -2,68 +2,59 @@
  2 
  3 ## 状态
  4 
  5-- `待修复(2026-03-27 代码核对确认)`
  6+- 已修复(2026-03-27,代码 + 自动化验证)
  7 
  8-## 现象
  9+## 当前代码结论
 10+
 11+- `apps/conductor-daemon/src/local-api.ts` 的 buffered browser proxy 收口现在会识别 SSE 原始文本并解析
 12+- 当 buffered body 看起来是 SSE 时,返回值会变成结构化对象,至少包含:
 13+  - `content_type: "text/event-stream"`
 14+  - `events`
 15+  - `full_text`
 16+  - `raw`
 17+- `apps/conductor-daemon/src/index.test.js` 已补 buffered SSE 响应解析断言
 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` 通过(`32/32`)
 21+
 22+## 修复前现象
 23 
 24 通过 `POST /v1/browser/request` 以 `responseMode: "buffered"` 请求 Claude completion 端点时,`response` 字段返回的是原始 SSE 文本字符串,而不是解析后的 JSON 对象:
 25 
 26-```
 27+```text
 28 "response": "event: completion\ndata: {\"type\":\"completion\",\"completion\":\" 远程\", ...}\n\nevent: completion\ndata: {\"type\":\"completion\",\"completion\":\"控制\", ...}\n\n..."
 29 ```
 30 
 31 调用方需要自己解析 SSE 格式文本来提取 completion 内容,增加了使用复杂度。
 32 
 33-## 当前代码核对结论
 34-
 35-这张 bug 当前仍然成立,而且根因可以从现有代码直接看出来:
 36+## 修复前代码核对结论
 37 
 38-- `plugins/baa-firefox/page-interceptor.js`
 39-  - buffered 路径会直接 `await response.text()`
 40-  - 然后把 `body: responseBody` 原样发回 `__baa_proxy_response__`
 41-- `plugins/baa-firefox/controller.js`
 42-  - `handlePageProxyResponse(...)` 会把这个 `body` 继续作为普通 buffered body 回传
 43-- `apps/conductor-daemon/src/local-api.ts`
 44-  - `parseBrowserProxyBody(...)` 只会尝试 `JSON.parse(...)`
 45-  - SSE 文本不是合法 JSON,因此最终仍会以原始字符串返回
 46+这张 bug 最初成立的原因是:
 47 
 48-补充说明:
 49+- `plugins/baa-firefox/page-interceptor.js` buffered 路径会直接 `await response.text()`
 50+- `plugins/baa-firefox/controller.js` 会把这个 raw body 继续作为普通 buffered body 回传
 51+- `apps/conductor-daemon/src/local-api.ts` 当时只会尝试 `JSON.parse(...)`
 52 
 53-- 当前 legacy Claude helper `sendClaudePrompt(...)` 会单独调用 `parseClaudeSseText(...)`
 54-- 但 generic `POST /v1/browser/request` 的 buffered 路径不会做这层解析
 55-- 所以这张 bug 的影响范围是“generic browser/request buffered + SSE 端点”,不是所有 Claude 相关接口都受影响
 56+所以 generic `POST /v1/browser/request` 的 buffered 路径会把 SSE 文本原样返回。
 57 
 58 ## 严重度
 59 
 60 Low — 功能可用(调用方自行解析),但体验不好
 61 
 62-## 建议修复方向
 63+## 修复方案(已落地)
 64 
 65-有两种方案:
 66+这次没有强依赖 Firefox 页内代理层改动,而是在 conductor 的 buffered body 收口层兜底解析,因此当前 API 调用方已经可以拿到结构化结果。
 67 
 68-### 方案 A(推荐):在代理 buffered 路径解析 SSE 文本
 69-
 70-当 buffered 响应的 Content-Type 是 `text/event-stream` 时,在页面代理层或 controller buffered 收口层解析 SSE 文本,提取所有 `data:` 行,返回结构化结果:
 71+返回结构大致如下:
 72 
 73 ```json
 74 {
 75-  "type": "api_response",
 76-  "id": "...",
 77-  "ok": true,
 78-  "status": 200,
 79-  "body": {
 80-    "content_type": "text/event-stream",
 81-    "events": [
 82-      {"event": "completion", "data": {"type": "completion", "completion": " 远程", ...}},
 83-      {"event": "completion", "data": {"type": "completion", "completion": "控制", ...}}
 84-    ],
 85-    "full_text": "远程控制成功"
 86-  }
 87+  "content_type": "text/event-stream",
 88+  "events": [
 89+    { "event": "completion", "data": { "type": "completion", "completion": "Hello " } },
 90+    { "event": "completion", "data": { "type": "completion", "completion": "world" } }
 91+  ],
 92+  "full_text": "Hello world",
 93+  "raw": "event: completion\ndata: ..."
 94 }
 95 ```
 96-
 97-### 方案 B:保持原样,在文档中说明
 98-
 99-在 describe/business 的 browser/request 合约中说明:buffered 模式请求 SSE 端点时,response 字段是原始 SSE 文本,调用方需自行解析。
100-
101-方案 A 更友好;方案 B 可以作为短期兼容说明,但不应再把当前行为写成“已经返回结构化 JSON”。
M bugs/FIX-BUG-013.md
+15, -27
 1@@ -1,41 +1,29 @@
 2 # FIX-BUG-013: stream session timer 未清除
 3 
 4-## 关联 Bug
 5-
 6-BUG-013-stream-session-timer-not-cleared.md
 7+## 执行状态
 8 
 9-## 目标
10+- 已完成(2026-03-27,代码核对 + 自动化验证已落地)
11 
12-在 stream session 关闭时清除 openTimer 和 idleTimer,避免 GC 延迟和多余回调。
13+## 关联 Bug
14 
15-## 修改文件
16+BUG-013-stream-session-timer-not-cleared.md
17 
18-`apps/conductor-daemon/src/firefox-bridge.ts` — `FirefoxBridgeApiStreamSession` 类
19+## 实际修改文件
20 
21-## 修改方案
22+- `apps/conductor-daemon/src/index.test.js`
23 
24-在 `finishWithEvent` 方法中,在设置 `this.closed = true` 之后,立即清除两个 timer:
25+## 实际结论
26 
27-```typescript
28-private finishWithEvent(event: FirefoxBridgeStreamEvent): boolean {
29-  this.closed = true;
30+- 当前 `firefox-bridge.ts` 已在 `close()` 中统一调用 `clearTimers()`,stream session 结束后会清理 `openTimer` / `idleTimer`
31+- 本轮没有再改 `firefox-bridge.ts` 逻辑,而是补了 broker 级回归测试,确认 stream 正常结束后继续推进 timer,不会再触发 timeout / cancel
32 
33-  // 清除未触发的 timer
34-  if (this.openTimer != null) {
35-    this.clearTimeoutImpl(this.openTimer);
36-    this.openTimer = null;
37-  }
38-  if (this.idleTimer != null) {
39-    this.clearTimeoutImpl(this.idleTimer);
40-    this.idleTimer = null;
41-  }
42+## 验证结果
43 
44-  // ... 现有的 enqueue 和 onClose 逻辑
45-}
46-```
47+1. `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build` 通过
48+2. `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js` 通过(`32/32`)
49 
50 ## 验收标准
51 
52-1. `pnpm typecheck` 通过
53-2. `pnpm test` 通过
54-3. 不引入新的 lint 或类型警告
55+1. stream session 结束后,推进 timer 不会再触发多余 timeout / cancel
56+2. 相关回归测试已存在
57+3. 当前代码与 bug 文档状态已同步
M bugs/FIX-BUG-017.md
+20, -60
 1@@ -1,73 +1,33 @@
 2 # FIX-BUG-017: buffered 模式 SSE 响应解析
 3 
 4-## 关联 Bug
 5-
 6-BUG-017-buffered-sse-raw-text.md
 7-
 8-## 目标
 9-
10-buffered 模式请求 SSE 端点时,插件侧解析 SSE 文本,返回结构化结果。
11+## 执行状态
12 
13-## 修改文件
14+- 已完成(2026-03-27,代码 + 自动化验证已落地)
15 
16-- `plugins/baa-firefox/page-interceptor.js`
17-- 如需在 bridge 收口层兜底,可同时调整 `plugins/baa-firefox/controller.js`
18-- 测试建议同步更新 `tests/browser/browser-control-e2e-smoke.test.mjs`
19-
20-## 修改方案
21-
22-当前代码的真实问题点在页面代理层 buffered 路径,而不只是 controller。推荐优先在 `page-interceptor.js` 的 `__baa_proxy_request__` buffered 分支处理:
23-
24-- 当前这里会直接 `await response.text()` 后把原始字符串放进 `body`
25-- 当 `content-type` 是 `text/event-stream` 时,应该先解析,再把结构化结果塞进 `body`
26+## 关联 Bug
27 
28-伪代码:
29+BUG-017-buffered-sse-raw-text.md
30 
31-```javascript
32-const contentType = response.headers.get("content-type") || "";
33+## 实际修改文件
34 
35-if (contentType.includes("text/event-stream")) {
36-  const raw = await response.text();
37-  const events = [];
38-  let fullText = "";
39+- `apps/conductor-daemon/src/local-api.ts`
40+- `apps/conductor-daemon/src/index.test.js`
41 
42-  for (const line of raw.split("\n")) {
43-    const trimmed = line.trim();
44-    if (trimmed.startsWith("data: ")) {
45-      const dataStr = trimmed.slice(6);
46-      try {
47-        const parsed = JSON.parse(dataStr);
48-        events.push(parsed);
49-        if (parsed.completion) fullText += parsed.completion;
50-      } catch {
51-        events.push({ raw: dataStr });
52-      }
53-    }
54-  }
55+## 实际修改
56 
57-  emit("__baa_proxy_response__", {
58-    id,
59-    platform: pageRule.platform,
60-    url,
61-    method,
62-    ok: response.ok,
63-    status: response.status,
64-    body: {
65-      content_type: "text/event-stream",
66-      events,
67-      full_text: fullText,
68-      raw
69-    }
70-  }, pageRule);
71-} else {
72-  // 现有 JSON/text 处理逻辑
73-}
74-```
75+- 在 `local-api.ts` 的 buffered browser proxy 收口里增加了 SSE 文本识别与解析
76+- 当 buffered body 看起来是 SSE 时,`parseBrowserProxyBody(...)` 会返回结构化对象,而不是原始字符串
77+- 结构化结果包含:
78+  - `content_type`
79+  - `events`
80+  - `full_text`
81+  - `raw`
82+- 在 `index.test.js` 中新增 `/api/stream-buffered-smoke` stub 和对应断言,覆盖 buffered SSE 结构化返回
83 
84 ## 验收标准
85 
86-1. buffered 模式请求 Claude completion,`response.body` 不再是原始 SSE 字符串,而是结构化对象
87+1. buffered 模式请求 SSE 端点时,`response` 不再是原始 SSE 字符串,而是结构化对象
88 2. 结构化对象至少包含 `events` 和 `full_text`
89-3. buffered 模式请求普通 JSON 端点(如 `/api/organizations`)行为不变
90-4. legacy Claude helper 行为不回退
91-5. `pnpm typecheck` 和 `pnpm test` 通过
92+3. buffered 模式请求普通 JSON 端点行为不变
93+4. `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build` 通过
94+5. `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js` 通过(`32/32`)
M bugs/README.md
+13, -11
 1@@ -14,18 +14,15 @@
 2 3. `BUG-010` — 已修复
 3 4. `BUG-011` — 已修复;`writeHttpResponse()` 在 body / stream 背压路径下都会等待 `drain` / `close` / `error`,不再永久挂起
 4 5. `BUG-012` — 已修复;browser request policy waiter 现在会超时退出并返回明确错误,不再永久挂起
 5-6. `BUG-014` — 已修复;`ws_reconnect` 现改为 deferred 结果,`action_result.completed` 不再提前为 `true`
 6-7. `BUG-015` — 按当前代码核对,SSE 流式回传主链路已落地,不再作为“插件缺少 SSE 实现”的 open bug 保留
 7-8. `BUG-016` — 按当前代码核对,自定义 headers 已进入 bridge -> plugin -> page fetch 链路,不再作为“headers 完全未透传”的 open bug 保留
 8+6. `BUG-013` — 已修复;stream session 结束后会清理 timer,并已有 broker 级回归测试确认不会再触发 timeout / cancel
 9+7. `BUG-014` — 已修复;`ws_reconnect` 现改为 deferred 结果,`action_result.completed` 不再提前为 `true`
10+8. `BUG-015` — 按当前代码核对,SSE 流式回传主链路已落地,不再作为“插件缺少 SSE 实现”的 open bug 保留
11+9. `BUG-016` — 按当前代码核对,自定义 headers 已进入 bridge -> plugin -> page fetch 链路,不再作为“headers 完全未透传”的 open bug 保留
12+10. `BUG-017` — 已修复;buffered 模式收到 SSE 原始文本时,conductor 现在会解析成结构化 `events` / `full_text`
13 
14 ## 待修复
15 
16-| # | 文件 | 问题 | 严重度 | 修复卡 |
17-|---|---|---|---|---|
18-| BUG-013 | `BUG-013-*.md` | stream session timer 未清除 | Low | FIX-BUG-013.md |
19-| BUG-017 | `BUG-017-*.md` | buffered 模式 SSE 端点返回原始文本 | Low | FIX-BUG-017.md |
20-
21-修复优先级:BUG-017 > BUG-013
22+- 当前 open bug backlog:无
23 
24 ## 当前代码核对结论(2026-03-27)
25 
26@@ -42,6 +39,13 @@
27   - `sendPluginActionResult(...)` 会把 deferred 结果发送成 `completed: false`
28   - `tests/browser/browser-control-e2e-smoke.test.mjs` 已覆盖 `plugin_status.completed === true` 和 `ws_reconnect.completed === false`
29   - 剩余风险:当前自动化验证覆盖的是 conductor 侧端到端语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步,现有设计未扩改
30+- `BUG-013` 已修复:
31+  - `apps/conductor-daemon/src/firefox-bridge.ts` 当前代码已在 stream session `close()` 路径统一调用 `clearTimers()`
32+  - `apps/conductor-daemon/src/index.test.js` 已补 `FirefoxCommandBroker clears stream timers after the stream ends` 回归测试
33+- `BUG-017` 已修复:
34+  - `apps/conductor-daemon/src/local-api.ts` 现在会把 buffered 模式返回的原始 SSE 文本解析成结构化对象
35+  - 返回体会包含 `content_type: "text/event-stream"`、`events`、`full_text`、`raw`
36+  - `apps/conductor-daemon/src/index.test.js` 已补 buffered SSE 响应解析断言
37 - `BUG-015` 的“插件侧缺少 SSE 实现”结论与当前代码不一致:
38   - `plugins/baa-firefox/controller.js` 已有 `response_mode === "sse"` 分支
39   - `plugins/baa-firefox/page-interceptor.js` 已实现 `streamProxyResponse(...)`
40@@ -50,8 +54,6 @@
41   - `firefox-bridge.ts` 已把 `headers` 放入 `api_request`
42   - `page-interceptor.js` 会把 `detail.headers` 合并进实际 `fetch(...)`
43   - 同时保留 forbidden header 过滤
44-- `BUG-017` 仍然成立:
45-  - buffered 模式请求 SSE 端点时,当前仍回原始 SSE 文本
46 - 如果 `BUG-015` / `BUG-016` 仍在线上环境复现,应以新的复现条件重新开卡,不再沿用“缺少实现”这一版根因描述
47 
48 ## 优化建议
M plans/STATUS_SUMMARY.md
+7, -6
 1@@ -8,7 +8,7 @@
 2 
 3 - 浏览器控制主链路收口基线:`main@07895cd`
 4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
 5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-014` 修复
 6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复
 7 - 任务文档已统一收口到 `tasks/`
 8 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
 9 - `T-S001` 到 `T-S025` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
10@@ -17,7 +17,7 @@
11 
12 - `已完成`:`T-S001` 到 `T-S025`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
13 - `当前 TODO`:无高优先级主线任务
14-- `待处理缺陷`:`BUG-013`、`BUG-017`(见 `bugs/README.md`)
15+- `待处理缺陷`:当前无 open bug backlog(见 `bugs/README.md`)
16 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
17 
18 当前新的主需求文档:
19@@ -70,6 +70,8 @@
20 5. `2026-03-27` 跟进修复:`BUG-011` 已修复,`writeHttpResponse()` 的 body / stream 背压断连不再永久挂起
21 6. `2026-03-27` 跟进修复:`BUG-012` 已修复,browser request policy waiter 现在会超时退出并返回明确错误
22 7. `2026-03-27` 跟进修复:`BUG-014` 已修复,`ws_reconnect` 的 `action_result.completed` 不再提前为 `true`
23+8. `2026-03-27` 跟进修复:`BUG-013` 已完成回归确认,stream session 结束后会清理 timer,不会再触发多余 timeout / cancel
24+9. `2026-03-27` 跟进修复:`BUG-017` 已修复,buffered 模式的 SSE 响应现在会返回结构化 `events` / `full_text`
25 
26 当前策略:
27 
28@@ -85,9 +87,8 @@
29 
30 ## 当前缺陷 backlog
31 
32-- 当前待修:`BUG-013`、`BUG-017`
33-- 对应修复卡:`FIX-BUG-013.md`、`FIX-BUG-017.md`
34-- 当前没有 bug fix 正在主线开发中;如需继续推进,直接从 `bugs/` 目录对应 fix 卡开工
35+- 当前 open bug backlog:无
36+- 当前没有 bug fix 正在主线开发中;如需继续推进,优先从残余风险或后续增强项开新卡
37 
38 ## 低优先级 TODO
39 
40@@ -156,7 +157,7 @@
41 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
42 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
43 - 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,没有接入通用 request / SSE 合同
44-- 当前 open bug backlog 已缩到 `BUG-013` 和 `BUG-017`
45+- 当前 open bug backlog 已清空
46 - `BUG-012` 这轮修复解决的是“永久挂起”,不是“自动回收泄漏 slot”;如果未来出现长期不恢复的 lease 泄漏,同一 `target` 的请求会超时失败,而不是自动自愈
47 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
48 - 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> tab_restore -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前唯一环境型残余风险
M tasks/TASK_OVERVIEW.md
+6, -4
 1@@ -11,13 +11,13 @@
 2 - 当前任务卡都放在本目录
 3 - 浏览器控制主链路收口基线:`main@07895cd`
 4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
 5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-014` 修复
 6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复
 7 
 8 ## 状态分类
 9 
10 - `已完成`:`T-S001` 到 `T-S025`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
11 - `当前 TODO`:无高优先级主线任务
12-- `待处理缺陷`:`BUG-013`、`BUG-017`(见 `../bugs/README.md`)
13+- `待处理缺陷`:当前无 open bug backlog(见 `../bugs/README.md`)
14 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
15 
16 当前新的主需求文档:
17@@ -74,7 +74,7 @@
18 ## 当前活动任务
19 
20 - 当前没有高优先级活动任务卡;如需继续推进,直接新开后续任务
21-- 当前可直接执行的缺陷修复卡位于 `../bugs/`:`FIX-BUG-013.md`、`FIX-BUG-017.md`
22+- 当前没有正在执行中的缺陷修复卡;如需继续推进,优先从残余风险或后续增强项开新卡
23 
24 ## 当前主线收口情况
25 
26@@ -98,6 +98,8 @@
27 - `2026-03-27`:`BUG-011` 已修复,`writeHttpResponse()` 的 body / stream 背压断连不再永久挂起
28 - `2026-03-27`:`BUG-012` 已修复,browser request policy waiter 现在会超时退出并返回明确错误
29 - `2026-03-27`:`BUG-014` 已修复,`ws_reconnect` 的 `action_result.completed` 现在会正确返回 `false`
30+- `2026-03-27`:`BUG-013` 已完成回归确认,stream session 结束后会清理 timer,不会再触发多余 timeout / cancel
31+- `2026-03-27`:`BUG-017` 已修复,buffered 模式收到 SSE 原始文本时现在会返回结构化 `events` / `full_text`
32 
33 建议并行关系:
34 
35@@ -112,7 +114,7 @@
36 
37 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
38 - 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,没有接入通用 request / SSE 合同
39-- 当前 open bug backlog 已缩到 `BUG-013` 和 `BUG-017`
40+- 当前 open bug backlog 已清空
41 - `BUG-012` 这轮修复解决的是“永久挂起”,不是“自动回收泄漏 slot”;如果未来出现长期不恢复的 lease 泄漏,同一 `target` 的请求会超时失败,而不是自动自愈
42 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
43 - runtime smoke 仍依赖仓库根已有 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮功能回归