- 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
+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` 自动清扫机制。
+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();
+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
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+- 这次新增的是自动化保护,而不是新的运行时代码分支
+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”。
+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 文档状态已同步
+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`)
+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 ## 优化建议
+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;这是当前唯一环境型残余风险
+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/` 等本地运行目录;这是现有脚本前提,不是本轮功能回归