baa-conductor

git clone 

commit
309eab2
parent
5cdeb5d
author
codex@macbookpro
date
2026-03-27 19:26:18 +0800 CST
feat: add baa execution persistence and delivery bridge
61 files changed,  +3632, -152
M PROGRESS/2026-03-27-current-code-progress.md
+20, -13
 1@@ -2,12 +2,12 @@
 2 
 3 ## 结论摘要
 4 
 5-- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032` 的功能落地。
 6-- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + ChatGPT / Gemini 最终消息 raw relay + live instruction ingest + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
 7+- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 的功能落地。
 8+- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + ChatGPT / Gemini 最终消息 raw relay + live instruction ingest + dedupe/journal 本地持久化 + artifact/upload/inject/send 主链 + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
 9 - 代码和自动化测试都表明:`/describe/business`、`/describe/control`、`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel` 已经形成正式主链路。
10 - 目前不应再把系统描述成“只有 Claude / ChatGPT request relay”;当前更准确的表述是“通用 browser surface 已落地,正式 request relay 已覆盖 Claude 和 ChatGPT,ChatGPT / Gemini 的 `browser.final_message` raw relay 也已接通,但这层仍只是最终消息中继,不等于 Gemini 正式 request relay 已转正”。
11-- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut、ChatGPT / Gemini 最终消息提取仍有平台特定边界,以及 live message dedupe / instruction dedupe 仍未持久化。
12-- 此前拆出的后续任务卡里,`T-S027`、`T-S028`、`T-S029`、`T-S030`、`T-S031`、`T-S032` 已完成;当前主要剩余 `T-S026`。
13+- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut、ChatGPT / Gemini 最终消息提取仍有平台特定边界,以及 BAA 仍只做到单节点本地持久化、有界 journal、单客户端单轮 delivery 首版和 Phase 1 exact target。
14+- 此前拆出的后续任务卡里,`T-S027`、`T-S028`、`T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 已完成;当前主要剩余 `T-S026`。
15 
16 ## 本次核对依据
17 
18@@ -26,9 +26,9 @@
19 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
20   - 结果:通过
21 - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
22-  - 结果:`44/44` 通过
23+  - 结果:`45/45` 通过
24 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
25-  - 结果:`6/6` 通过
26+  - 结果:`8/8` 通过
27 
28 ## 当前已完成功能
29 
30@@ -309,10 +309,11 @@
31 - 但它的最终文本提取基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT。
32 - 当前保留 synthetic `assistant_message_id` 兜底,因此这层应表述为“已接通 raw relay,但平台提取稳定性仍有边界”,不是“Gemini 整个平台能力都已转正”。
33 
34-### 4. live message dedupe 和 instruction dedupe 仍是内存态
35+### 4. live message dedupe 和 instruction dedupe 已持久化,但仍只做单节点本地恢复
36 
37-- `browser.final_message` 当前已经 live 接入 instruction center,但 live message dedupe 和 instruction dedupe 都还是进程内内存态。
38-- 因此进程重启后,这两类 dedupe 状态都不会保留。
39+- `browser.final_message` 当前已经 live 接入 instruction center,live message dedupe 和 instruction dedupe 也都已落到本地 control-plane sqlite。
40+- 因此进程重启后,这两类 dedupe 状态会保留。
41+- 当前残余边界不是“会丢失”,而是“只做单节点本地持久化,不做跨节点共享”。
42 
43 ### 5. 风控状态仍是进程内内存态
44 
45@@ -337,13 +338,19 @@
46 - 但它仍依赖浏览器里真实捕获到的有效登录态 / header,不是“无前提可用”的平台。
47 - 另外,ChatGPT 也没有 Claude 风格的 prompt shortcut;当前正式支持面仍是 raw relay,不是 prompt helper。
48 
49-### 9. 当前摘要只保留最近一次 live ingest / execute,且仍只覆盖 Phase 1 exact target
50+### 9. 当前摘要已落到 bounded journal,live 路径也已接到 delivery bridge,但仍只覆盖 Phase 1 exact target
51 
52-- 当前 live ingest 读面只保留最近一次 ingest / execute 摘要,不落库。
53+- 当前 live ingest / execute 摘要已经落到有界 journal,并能在重启后恢复到:
54+  - Firefox WS `state_snapshot.snapshot.browser.instruction_ingest`
55+  - `GET /v1/browser` → `data.instruction_ingest`
56+- 当前 journal 只保留最近窗口,不扩成无限历史。
57+- 插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程。
58+- artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做。
59+- 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版。
60 - 当前仍只允许 Phase 1 精确 target:
61   - `conductor`
62   - `system`
63-- 虽然 service-side artifact center core 已落地,但 live 路径还没有把执行结果真正接到 artifact / upload / inject / send。
64+- 当前 live 路径已经把执行结果接到 artifact / upload / inject / send,但不扩到跨节点或完整 task/run 编排。
65 
66 ## 已拆出的后续任务
67 
68@@ -366,4 +373,4 @@
69 
70 如果只写一段给外部协作者看,可以用下面这版:
71 
72-> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT request relay 主链路、ChatGPT / Gemini 最终消息 raw relay、conductor 侧 live instruction ingest,以及 service-side artifact center core。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化、`browser.final_message` 最近快照、`instruction_ingest` 最近摘要,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(44/44)和 browser-control e2e smoke(6/6)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、ChatGPT / Gemini 最终消息提取仍有平台边界、live message dedupe / instruction dedupe 仍是内存态、最近摘要不落库,以及 live 路径还没有真正接到 artifact / upload / inject / send。
73+> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT request relay 主链路、ChatGPT / Gemini 最终消息 raw relay、conductor 侧 live instruction ingest、dedupe / execution journal 本地持久化,以及 artifact / upload / inject / send 主链。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化、`browser.final_message` 最近快照、`instruction_ingest` 最近摘要、upload receipt barrier,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(45/45)和 browser-control e2e smoke(8/8)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、ChatGPT / Gemini 最终消息提取仍有平台边界、插件侧 upload / inject / send 仍是首版 DOM heuristic、artifact payload 仍以本地 `download_url` 的 base64 JSON 形式提供且未覆盖大二进制 / download 闭环、BAA 仍只做单节点本地持久化、execution journal 只保留最近窗口,以及 live 执行路径仍只覆盖 Phase 1 exact target。
M apps/conductor-daemon/src/artifacts.test.js
+1, -1
1@@ -101,7 +101,7 @@ test("artifact materializer turns exec output into artifact, manifest, and deliv
2 
3     assert.equal(plan.uploads.length, 2);
4     assert.deepEqual(plan.pendingBarriers, ["upload_receipt"]);
5-    assert.equal(plan.receiptBarrierImplemented, false);
6+    assert.equal(plan.receiptBarrierImplemented, true);
7     assert.match(indexText, /instruction_id: inst_exec_01/);
8     assert.match(indexText, /baa-result_trace-artifacts_r03_b02_exec_conductor_ok\.log/);
9     assert.match(indexText, /baa-manifest_trace-artifacts_r03\.json/);
M apps/conductor-daemon/src/artifacts/delivery-plan.ts
+6, -2
 1@@ -9,12 +9,16 @@ function toUploadItem(artifact: {
 2   filename: string;
 3   localPath: string;
 4   mimeType: string;
 5+  sha256: string;
 6+  sizeBytes: number;
 7 }): BaaDeliveryUploadItem {
 8   return {
 9     artifactId: artifact.artifactId,
10     filename: artifact.filename,
11     localPath: artifact.localPath,
12-    mimeType: artifact.mimeType
13+    mimeType: artifact.mimeType,
14+    sha256: artifact.sha256,
15+    sizeBytes: artifact.sizeBytes
16   };
17 }
18 
19@@ -32,7 +36,7 @@ export function buildBaaDeliveryPlan(input: BuildBaaDeliveryPlanInput): BaaDeliv
20     messageText: input.indexText,
21     pendingBarriers: uploads.length === 0 ? [] : ["upload_receipt"],
22     planId: `plan_${input.materialization.traceId}_${input.materialization.roundId}`,
23-    receiptBarrierImplemented: false,
24+    receiptBarrierImplemented: true,
25     roundId: input.materialization.roundId,
26     target: input.target,
27     traceId: input.materialization.traceId,
M apps/conductor-daemon/src/artifacts/index.ts
+1, -0
1@@ -2,3 +2,4 @@ export * from "./types.js";
2 export * from "./materialize.js";
3 export * from "./manifest.js";
4 export * from "./delivery-plan.js";
5+export * from "./upload-session.js";
M apps/conductor-daemon/src/artifacts/manifest.ts
+2, -2
 1@@ -68,8 +68,8 @@ export async function buildBaaArtifactManifest(
 2     createdAt: materialization.createdAt,
 3     manifestId: `mf_${sanitizeSegment(materialization.traceId)}_${sanitizeSegment(materialization.roundId)}`,
 4     notes: [
 5-      "Service-side artifact materialization, manifest generation, and delivery-plan generation are implemented in conductor.",
 6-      "Browser upload/download execution and upload receipt barrier are not implemented in this phase."
 7+      "Service-side artifact materialization, manifest generation, delivery-plan generation, and upload receipt barrier are implemented in conductor.",
 8+      "Firefox bridge delivery currently covers upload_artifacts, upload_receipt, inject_message, and send_message for the single-client live path."
 9     ],
10     resultCount: materialization.results.length,
11     results: toManifestResultEntry(materialization),
M apps/conductor-daemon/src/artifacts/types.ts
+55, -0
 1@@ -139,6 +139,8 @@ export interface BaaDeliveryUploadItem {
 2   filename: string;
 3   localPath: string;
 4   mimeType: string;
 5+  sha256: string;
 6+  sizeBytes: number;
 7 }
 8 
 9 export interface BaaDeliveryPlan {
10@@ -164,3 +166,56 @@ export interface BuildBaaDeliveryPlanInput {
11   materialization: BaaArtifactMaterialization;
12   target: string;
13 }
14+
15+export type BaaDeliverySessionStage =
16+  | "idle"
17+  | "awaiting_receipts"
18+  | "injecting"
19+  | "sending"
20+  | "completed"
21+  | "failed";
22+
23+export interface BaaDeliveryUploadReceipt {
24+  artifactId: string;
25+  attempts: number;
26+  error: string | null;
27+  filename: string;
28+  ok: boolean;
29+  receivedAt: number | null;
30+  remoteHandle: string | null;
31+  sha256: string;
32+  sizeBytes: number;
33+}
34+
35+export interface BaaDeliverySessionSnapshot {
36+  autoSend: boolean;
37+  clientId: string | null;
38+  completedAt: number | null;
39+  connectionId: string | null;
40+  conversationId: string | null;
41+  createdAt: number;
42+  failedAt: number | null;
43+  failedReason: string | null;
44+  injectCompletedAt: number | null;
45+  injectRequestId: string | null;
46+  injectStartedAt: number | null;
47+  manifestId: string;
48+  pendingUploadArtifactIds: string[];
49+  planId: string;
50+  platform: string;
51+  receiptConfirmedCount: number;
52+  roundId: string;
53+  sendCompletedAt: number | null;
54+  sendRequestId: string | null;
55+  sendStartedAt: number | null;
56+  stage: BaaDeliverySessionStage;
57+  traceId: string;
58+  uploadCount: number;
59+  uploadDispatchedAt: number | null;
60+  uploadReceipts: BaaDeliveryUploadReceipt[];
61+}
62+
63+export interface BaaDeliveryBridgeSnapshot {
64+  activeSessionCount: number;
65+  lastSession: BaaDeliverySessionSnapshot | null;
66+}
A apps/conductor-daemon/src/artifacts/upload-session.ts
+583, -0
  1@@ -0,0 +1,583 @@
  2+import { mkdir, readFile } from "node:fs/promises";
  3+import { tmpdir } from "node:os";
  4+import { join } from "node:path";
  5+
  6+import type { BrowserBridgeController } from "../browser-types.js";
  7+import type { BaaInstructionProcessResult } from "../instructions/types.js";
  8+
  9+import { buildBaaDeliveryPlan } from "./delivery-plan.js";
 10+import { buildBaaArtifactManifest, renderBaaArtifactIndexText } from "./manifest.js";
 11+import { materializeBaaExecutionArtifacts } from "./materialize.js";
 12+import type {
 13+  BaaDeliveryBridgeSnapshot,
 14+  BaaDeliveryPlan,
 15+  BaaDeliverySessionSnapshot,
 16+  BaaDeliveryUploadItem,
 17+  BaaDeliveryUploadReceipt
 18+} from "./types.js";
 19+
 20+type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
 21+
 22+const DEFAULT_UPLOAD_RECEIPT_TIMEOUT_MS = 60_000;
 23+const DEFAULT_COMPLETED_SESSION_TTL_MS = 10 * 60_000;
 24+
 25+interface BaaArtifactContentPayload {
 26+  artifact_id: string;
 27+  content_base64: string;
 28+  encoding: "base64";
 29+  filename: string;
 30+  mime_type: string;
 31+  sha256: string;
 32+  size_bytes: number;
 33+}
 34+
 35+interface BaaDeliveryReceiptBarrier {
 36+  promise: Promise<void>;
 37+  reject: (error: Error) => void;
 38+  resolve: () => void;
 39+  settled: boolean;
 40+  timer: TimeoutHandle;
 41+}
 42+
 43+interface BaaDeliverySessionRecord {
 44+  barrier: BaaDeliveryReceiptBarrier | null;
 45+  expiresAt: number;
 46+  plan: BaaDeliveryPlan;
 47+  snapshot: BaaDeliverySessionSnapshot;
 48+  targetClientId: string | null;
 49+  targetConnectionId: string | null;
 50+  uploadsByArtifactId: Map<string, BaaDeliveryUploadItem>;
 51+}
 52+
 53+export interface BaaArtifactDeliveryBridgeOptions {
 54+  baseUrlLoader: () => string;
 55+  bridge: BrowserBridgeController;
 56+  now?: () => number;
 57+  onChange?: (() => Promise<void> | void) | null;
 58+  outputDirLoader?: (() => string | null) | null;
 59+}
 60+
 61+export interface BaaArtifactDeliveryInput {
 62+  assistantMessageId: string;
 63+  autoSend?: boolean;
 64+  clientId?: string | null;
 65+  connectionId?: string | null;
 66+  conversationId?: string | null;
 67+  platform: string;
 68+  processResult: BaaInstructionProcessResult | null;
 69+}
 70+
 71+function sanitizePathSegment(value: string): string {
 72+  const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-");
 73+  const collapsed = normalized.replace(/-+/gu, "-").replace(/^-|-$/gu, "");
 74+  return collapsed === "" ? "unknown" : collapsed;
 75+}
 76+
 77+function cloneUploadReceipt(receipt: BaaDeliveryUploadReceipt): BaaDeliveryUploadReceipt {
 78+  return {
 79+    artifactId: receipt.artifactId,
 80+    attempts: receipt.attempts,
 81+    error: receipt.error,
 82+    filename: receipt.filename,
 83+    ok: receipt.ok,
 84+    receivedAt: receipt.receivedAt,
 85+    remoteHandle: receipt.remoteHandle,
 86+    sha256: receipt.sha256,
 87+    sizeBytes: receipt.sizeBytes
 88+  };
 89+}
 90+
 91+function cloneSessionSnapshot(snapshot: BaaDeliverySessionSnapshot): BaaDeliverySessionSnapshot {
 92+  return {
 93+    autoSend: snapshot.autoSend,
 94+    clientId: snapshot.clientId,
 95+    completedAt: snapshot.completedAt,
 96+    connectionId: snapshot.connectionId,
 97+    conversationId: snapshot.conversationId,
 98+    createdAt: snapshot.createdAt,
 99+    failedAt: snapshot.failedAt,
100+    failedReason: snapshot.failedReason,
101+    injectCompletedAt: snapshot.injectCompletedAt,
102+    injectRequestId: snapshot.injectRequestId,
103+    injectStartedAt: snapshot.injectStartedAt,
104+    manifestId: snapshot.manifestId,
105+    pendingUploadArtifactIds: [...snapshot.pendingUploadArtifactIds],
106+    planId: snapshot.planId,
107+    platform: snapshot.platform,
108+    receiptConfirmedCount: snapshot.receiptConfirmedCount,
109+    roundId: snapshot.roundId,
110+    sendCompletedAt: snapshot.sendCompletedAt,
111+    sendRequestId: snapshot.sendRequestId,
112+    sendStartedAt: snapshot.sendStartedAt,
113+    stage: snapshot.stage,
114+    traceId: snapshot.traceId,
115+    uploadCount: snapshot.uploadCount,
116+    uploadDispatchedAt: snapshot.uploadDispatchedAt,
117+    uploadReceipts: snapshot.uploadReceipts.map(cloneUploadReceipt)
118+  };
119+}
120+
121+function normalizeNonEmptyString(value: unknown): string | null {
122+  if (typeof value !== "string") {
123+    return null;
124+  }
125+
126+  const normalized = value.trim();
127+  return normalized === "" ? null : normalized;
128+}
129+
130+function readPositiveInteger(value: unknown): number | null {
131+  return typeof value === "number" && Number.isFinite(value) && value > 0
132+    ? Math.round(value)
133+    : null;
134+}
135+
136+function asRecord(value: unknown): Record<string, unknown> | null {
137+  if (value == null || typeof value !== "object" || Array.isArray(value)) {
138+    return null;
139+  }
140+
141+  return value as Record<string, unknown>;
142+}
143+
144+export class BaaArtifactDeliveryBridge {
145+  private readonly baseUrlLoader: () => string;
146+  private readonly bridge: BrowserBridgeController;
147+  private lastSession: BaaDeliverySessionSnapshot | null = null;
148+  private readonly now: () => number;
149+  private readonly onChange: (() => Promise<void> | void) | null;
150+  private readonly outputDirLoader: () => string | null;
151+  private readonly sessions = new Map<string, BaaDeliverySessionRecord>();
152+
153+  constructor(options: BaaArtifactDeliveryBridgeOptions) {
154+    this.baseUrlLoader = options.baseUrlLoader;
155+    this.bridge = options.bridge;
156+    this.now = options.now ?? (() => Date.now());
157+    this.onChange = options.onChange ?? null;
158+    this.outputDirLoader = options.outputDirLoader ?? (() => null);
159+  }
160+
161+  getSnapshot(): BaaDeliveryBridgeSnapshot {
162+    this.cleanupExpiredSessions();
163+
164+    return {
165+      activeSessionCount: [...this.sessions.values()].filter((entry) =>
166+        entry.snapshot.stage !== "completed" && entry.snapshot.stage !== "failed"
167+      ).length,
168+      lastSession: this.lastSession == null ? null : cloneSessionSnapshot(this.lastSession)
169+    };
170+  }
171+
172+  async deliver(input: BaaArtifactDeliveryInput): Promise<BaaDeliverySessionSnapshot | null> {
173+    this.cleanupExpiredSessions();
174+
175+    const processResult = input.processResult;
176+
177+    if (processResult == null || processResult.executions.length === 0) {
178+      return null;
179+    }
180+
181+    const traceId = `delivery_${sanitizePathSegment(input.assistantMessageId)}`;
182+    const roundId = `round_${this.now()}`;
183+    const outputDir = await this.prepareOutputDir(traceId, roundId);
184+    const instructionById = new Map(
185+      processResult.instructions.map((instruction) => [instruction.instructionId, instruction])
186+    );
187+    const materialization = await materializeBaaExecutionArtifacts(
188+      processResult.executions.map((execution, index) => ({
189+        blockIndex: instructionById.get(execution.instructionId)?.blockIndex ?? null,
190+        execution,
191+        sequence: index + 1
192+      })),
193+      {
194+        outputDir,
195+        roundId,
196+        traceId
197+      }
198+    );
199+    const manifestBundle = await buildBaaArtifactManifest(materialization);
200+    const indexText = renderBaaArtifactIndexText(materialization, manifestBundle);
201+    const plan = buildBaaDeliveryPlan({
202+      autoSend: input.autoSend ?? true,
203+      conversationId: input.conversationId ?? null,
204+      indexText,
205+      manifestBundle,
206+      materialization,
207+      target: `browser.${input.platform}`
208+    });
209+    const createdAt = this.now();
210+    const session: BaaDeliverySessionSnapshot = {
211+      autoSend: plan.autoSend,
212+      clientId: input.clientId ?? null,
213+      completedAt: null,
214+      connectionId: input.connectionId ?? null,
215+      conversationId: plan.conversationId,
216+      createdAt,
217+      failedAt: null,
218+      failedReason: null,
219+      injectCompletedAt: null,
220+      injectRequestId: null,
221+      injectStartedAt: null,
222+      manifestId: plan.manifestId,
223+      pendingUploadArtifactIds: plan.uploads.map((upload) => upload.artifactId),
224+      planId: plan.planId,
225+      platform: input.platform,
226+      receiptConfirmedCount: 0,
227+      roundId: plan.roundId,
228+      sendCompletedAt: null,
229+      sendRequestId: null,
230+      sendStartedAt: null,
231+      stage: plan.uploads.length === 0 ? "injecting" : "awaiting_receipts",
232+      traceId: plan.traceId,
233+      uploadCount: plan.uploads.length,
234+      uploadDispatchedAt: null,
235+      uploadReceipts: plan.uploads.map((upload) => ({
236+        artifactId: upload.artifactId,
237+        attempts: 0,
238+        error: null,
239+        filename: upload.filename,
240+        ok: false,
241+        receivedAt: null,
242+        remoteHandle: null,
243+        sha256: upload.sha256,
244+        sizeBytes: upload.sizeBytes
245+      }))
246+    };
247+    const barrier = plan.uploads.length === 0 ? null : this.createReceiptBarrier(plan.planId);
248+    const record: BaaDeliverySessionRecord = {
249+      barrier,
250+      expiresAt: createdAt + DEFAULT_COMPLETED_SESSION_TTL_MS,
251+      plan,
252+      snapshot: session,
253+      targetClientId: input.clientId ?? null,
254+      targetConnectionId: input.connectionId ?? null,
255+      uploadsByArtifactId: new Map(plan.uploads.map((upload) => [upload.artifactId, upload]))
256+    };
257+
258+    this.sessions.set(plan.planId, record);
259+    this.lastSession = cloneSessionSnapshot(session);
260+    this.signalChange();
261+
262+    try {
263+      if (plan.uploads.length > 0) {
264+        const dispatch = this.bridge.uploadArtifacts({
265+          clientId: input.clientId,
266+          conversationId: plan.conversationId,
267+          manifestId: plan.manifestId,
268+          planId: plan.planId,
269+          platform: input.platform,
270+          uploads: plan.uploads.map((upload) => ({
271+            artifactId: upload.artifactId,
272+            downloadUrl: this.buildArtifactDownloadUrl(plan.planId, upload.artifactId),
273+            filename: upload.filename,
274+            mimeType: upload.mimeType,
275+            sha256: upload.sha256,
276+            sizeBytes: upload.sizeBytes
277+          }))
278+        });
279+
280+        record.snapshot.uploadDispatchedAt = dispatch.dispatchedAt;
281+        this.captureLastSession(record.snapshot);
282+
283+        await record.barrier?.promise;
284+      }
285+
286+      const injectDispatch = this.bridge.injectMessage({
287+        clientId: input.clientId,
288+        conversationId: plan.conversationId,
289+        messageText: plan.messageText,
290+        planId: plan.planId,
291+        platform: input.platform
292+      });
293+
294+      record.snapshot.injectRequestId = injectDispatch.requestId;
295+      record.snapshot.injectStartedAt = injectDispatch.dispatchedAt;
296+      record.snapshot.stage = "injecting";
297+      this.captureLastSession(record.snapshot);
298+      const injectResult = await injectDispatch.result;
299+
300+      if (injectResult.accepted !== true || injectResult.failed === true) {
301+        throw new Error(injectResult.reason ?? "browser inject_message failed");
302+      }
303+
304+      record.snapshot.injectCompletedAt = this.now();
305+
306+      if (plan.autoSend) {
307+        const sendDispatch = this.bridge.sendMessage({
308+          clientId: input.clientId,
309+          conversationId: plan.conversationId,
310+          planId: plan.planId,
311+          platform: input.platform
312+        });
313+
314+        record.snapshot.sendRequestId = sendDispatch.requestId;
315+        record.snapshot.sendStartedAt = sendDispatch.dispatchedAt;
316+        record.snapshot.stage = "sending";
317+        this.captureLastSession(record.snapshot);
318+        const sendResult = await sendDispatch.result;
319+
320+        if (sendResult.accepted !== true || sendResult.failed === true) {
321+          throw new Error(sendResult.reason ?? "browser send_message failed");
322+        }
323+
324+        record.snapshot.sendCompletedAt = this.now();
325+      }
326+
327+      record.snapshot.completedAt = this.now();
328+      record.snapshot.stage = "completed";
329+      record.expiresAt = record.snapshot.completedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
330+      this.captureLastSession(record.snapshot);
331+      return cloneSessionSnapshot(record.snapshot);
332+    } catch (error) {
333+      this.failSession(
334+        record,
335+        error instanceof Error ? error.message : String(error)
336+      );
337+      throw error;
338+    }
339+  }
340+
341+  handleUploadReceipt(
342+    connectionId: string,
343+    payload: Record<string, unknown>
344+  ): boolean {
345+    this.cleanupExpiredSessions();
346+
347+    const planId = normalizeNonEmptyString(payload.plan_id ?? payload.planId);
348+
349+    if (planId == null) {
350+      return false;
351+    }
352+
353+    const session = this.sessions.get(planId);
354+
355+    if (session == null) {
356+      return false;
357+    }
358+
359+    if (session.targetConnectionId != null && session.targetConnectionId !== connectionId) {
360+      return false;
361+    }
362+
363+    const receipts = Array.isArray(payload.receipts) ? payload.receipts : [];
364+
365+    if (receipts.length === 0) {
366+      return false;
367+    }
368+
369+    let touched = false;
370+
371+    for (const entry of receipts) {
372+      const record = asRecord(entry);
373+
374+      if (record == null) {
375+        continue;
376+      }
377+
378+      const artifactId = normalizeNonEmptyString(record.artifact_id ?? record.artifactId);
379+
380+      if (artifactId == null) {
381+        continue;
382+      }
383+
384+      const upload = session.uploadsByArtifactId.get(artifactId);
385+      const receipt = session.snapshot.uploadReceipts.find((candidate) => candidate.artifactId === artifactId);
386+
387+      if (upload == null || receipt == null) {
388+        continue;
389+      }
390+
391+      receipt.attempts = readPositiveInteger(record.attempts) ?? Math.max(1, receipt.attempts);
392+      receipt.error = normalizeNonEmptyString(record.error ?? record.reason);
393+      receipt.ok = record.ok !== false;
394+      receipt.receivedAt = readPositiveInteger(record.received_at ?? record.receivedAt) ?? this.now();
395+      receipt.remoteHandle = normalizeNonEmptyString(record.remote_handle ?? record.remoteHandle);
396+      touched = true;
397+
398+      if (receipt.ok) {
399+        session.snapshot.pendingUploadArtifactIds = session.snapshot.pendingUploadArtifactIds.filter(
400+          (candidate) => candidate !== artifactId
401+        );
402+      } else {
403+        this.failSession(
404+          session,
405+          receipt.error ?? `artifact ${upload.filename} upload failed`
406+        );
407+      }
408+    }
409+
410+    if (!touched) {
411+      return false;
412+    }
413+
414+    session.snapshot.receiptConfirmedCount = session.snapshot.uploadReceipts.filter((entry) => entry.ok).length;
415+    this.captureLastSession(session.snapshot);
416+
417+    if (
418+      session.barrier != null
419+      && session.barrier.settled === false
420+      && session.snapshot.pendingUploadArtifactIds.length === 0
421+      && session.snapshot.uploadReceipts.every((entry) => entry.ok)
422+    ) {
423+      session.barrier.settled = true;
424+      globalThis.clearTimeout(session.barrier.timer);
425+      session.barrier.resolve();
426+    }
427+
428+    return true;
429+  }
430+
431+  handleConnectionClosed(connectionId: string, reason?: string | null): void {
432+    for (const session of this.sessions.values()) {
433+      if (session.targetConnectionId !== connectionId) {
434+        continue;
435+      }
436+
437+      if (session.snapshot.stage === "completed" || session.snapshot.stage === "failed") {
438+        continue;
439+      }
440+
441+      this.failSession(
442+        session,
443+        normalizeNonEmptyString(reason) ?? "firefox delivery client disconnected"
444+      );
445+    }
446+  }
447+
448+  readArtifactContent(
449+    planId: string,
450+    artifactId: string
451+  ): Promise<BaaArtifactContentPayload | null> {
452+    this.cleanupExpiredSessions();
453+    return this.loadArtifactContent(planId, artifactId);
454+  }
455+
456+  stop(): void {
457+    for (const session of this.sessions.values()) {
458+      if (session.barrier != null && session.barrier.settled === false) {
459+        session.barrier.settled = true;
460+        globalThis.clearTimeout(session.barrier.timer);
461+        session.barrier.reject(new Error("artifact delivery bridge stopped"));
462+      }
463+    }
464+
465+    this.sessions.clear();
466+  }
467+
468+  private captureLastSession(snapshot: BaaDeliverySessionSnapshot): void {
469+    this.lastSession = cloneSessionSnapshot(snapshot);
470+    this.signalChange();
471+  }
472+
473+  private cleanupExpiredSessions(): void {
474+    const now = this.now();
475+
476+    for (const [planId, session] of this.sessions.entries()) {
477+      if (session.expiresAt > now) {
478+        continue;
479+      }
480+
481+      if (session.barrier != null && session.barrier.settled === false) {
482+        session.barrier.settled = true;
483+        globalThis.clearTimeout(session.barrier.timer);
484+        session.barrier.reject(new Error("artifact delivery receipt timed out"));
485+      }
486+
487+      this.sessions.delete(planId);
488+    }
489+  }
490+
491+  private createReceiptBarrier(planId: string): BaaDeliveryReceiptBarrier {
492+    let resolveBarrier!: () => void;
493+    let rejectBarrier!: (error: Error) => void;
494+    const promise = new Promise<void>((resolve, reject) => {
495+      resolveBarrier = resolve;
496+      rejectBarrier = reject;
497+    });
498+    void promise.catch(() => {});
499+    const timer = globalThis.setTimeout(() => {
500+      const session = this.sessions.get(planId);
501+
502+      if (session == null || session.barrier == null || session.barrier.settled) {
503+        return;
504+      }
505+
506+      this.failSession(session, `upload receipt barrier timed out for plan "${planId}"`);
507+    }, DEFAULT_UPLOAD_RECEIPT_TIMEOUT_MS);
508+
509+    return {
510+      promise,
511+      reject: rejectBarrier,
512+      resolve: resolveBarrier,
513+      settled: false,
514+      timer
515+    };
516+  }
517+
518+  private async loadArtifactContent(
519+    planId: string,
520+    artifactId: string
521+  ): Promise<BaaArtifactContentPayload | null> {
522+    const session = this.sessions.get(planId);
523+
524+    if (session == null) {
525+      return null;
526+    }
527+
528+    const upload = session.uploadsByArtifactId.get(artifactId);
529+
530+    if (upload == null) {
531+      return null;
532+    }
533+
534+    const content = await readFile(upload.localPath);
535+
536+    return {
537+      artifact_id: artifactId,
538+      content_base64: content.toString("base64"),
539+      encoding: "base64",
540+      filename: upload.filename,
541+      mime_type: upload.mimeType,
542+      sha256: upload.sha256,
543+      size_bytes: upload.sizeBytes
544+    };
545+  }
546+
547+  private buildArtifactDownloadUrl(planId: string, artifactId: string): string {
548+    const baseUrl = this.baseUrlLoader().replace(/\/+$/u, "");
549+    return `${baseUrl}/v1/browser/delivery/artifacts/${encodeURIComponent(planId)}/${encodeURIComponent(artifactId)}`;
550+  }
551+
552+  private failSession(session: BaaDeliverySessionRecord, reason: string): void {
553+    if (session.barrier != null && session.barrier.settled === false) {
554+      session.barrier.settled = true;
555+      globalThis.clearTimeout(session.barrier.timer);
556+      session.barrier.reject(new Error(reason));
557+    }
558+
559+    const failedAt = this.now();
560+
561+    session.snapshot.failedAt = failedAt;
562+    session.snapshot.failedReason = reason;
563+    session.snapshot.stage = "failed";
564+    session.expiresAt = failedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
565+    this.captureLastSession(session.snapshot);
566+  }
567+
568+  private async prepareOutputDir(traceId: string, roundId: string): Promise<string> {
569+    const baseDir = this.outputDirLoader() ?? tmpdir();
570+    const outputDir = join(baseDir, "baa-artifact-delivery", traceId, roundId);
571+    await mkdir(outputDir, {
572+      recursive: true
573+    });
574+    return outputDir;
575+  }
576+
577+  private signalChange(): void {
578+    if (this.onChange == null) {
579+      return;
580+    }
581+
582+    void Promise.resolve(this.onChange()).catch(() => {});
583+  }
584+}
M apps/conductor-daemon/src/browser-types.ts
+30, -0
 1@@ -1,4 +1,5 @@
 2 import type { BaaLiveInstructionIngestSnapshot } from "./instructions/ingest.js";
 3+import type { BaaDeliveryBridgeSnapshot } from "./artifacts/types.js";
 4 
 5 export type BrowserBridgeLoginStatus = "fresh" | "stale" | "lost";
 6 
 7@@ -145,6 +146,7 @@ export interface BrowserBridgeStateSnapshot {
 8   active_connection_id: string | null;
 9   client_count: number;
10   clients: BrowserBridgeClientSnapshot[];
11+  delivery: BaaDeliveryBridgeSnapshot;
12   instruction_ingest: BaaLiveInstructionIngestSnapshot;
13   ws_path: string;
14   ws_url: string | null;
15@@ -257,6 +259,34 @@ export interface BrowserBridgeController {
16     streamId?: string | null;
17     timeoutMs?: number | null;
18   }): Promise<BrowserBridgeApiResponse>;
19+  injectMessage(input: {
20+    clientId?: string | null;
21+    conversationId?: string | null;
22+    messageText: string;
23+    planId: string;
24+    platform: string;
25+  }): BrowserBridgeActionDispatch;
26+  sendMessage(input: {
27+    clientId?: string | null;
28+    conversationId?: string | null;
29+    planId: string;
30+    platform: string;
31+  }): BrowserBridgeActionDispatch;
32+  uploadArtifacts(input: {
33+    clientId?: string | null;
34+    conversationId?: string | null;
35+    manifestId: string;
36+    planId: string;
37+    platform: string;
38+    uploads: Array<{
39+      artifactId: string;
40+      downloadUrl: string;
41+      filename: string;
42+      mimeType: string;
43+      sha256: string;
44+      sizeBytes: number;
45+    }>;
46+  }): BrowserBridgeDispatchReceipt;
47   cancelApiRequest(input: {
48     clientId?: string | null;
49     platform?: string | null;
M apps/conductor-daemon/src/firefox-bridge.ts
+85, -0
  1@@ -17,6 +17,9 @@ type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
  2 export type FirefoxBridgeResponseMode = "buffered" | "sse";
  3 export type FirefoxBridgeOutboundCommandType =
  4   | "api_request"
  5+  | "browser.inject_message"
  6+  | "browser.send_message"
  7+  | "browser.upload_artifacts"
  8   | "controller_reload"
  9   | "open_tab"
 10   | "plugin_status"
 11@@ -94,6 +97,34 @@ export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarge
 12   timeoutMs?: number | null;
 13 }
 14 
 15+export interface FirefoxUploadArtifactsCommandInput extends FirefoxBridgeCommandTarget {
 16+  conversationId?: string | null;
 17+  manifestId: string;
 18+  planId: string;
 19+  platform: string;
 20+  uploads: Array<{
 21+    artifactId: string;
 22+    downloadUrl: string;
 23+    filename: string;
 24+    mimeType: string;
 25+    sha256: string;
 26+    sizeBytes: number;
 27+  }>;
 28+}
 29+
 30+export interface FirefoxInjectMessageCommandInput extends FirefoxBridgeCommandTarget {
 31+  conversationId?: string | null;
 32+  messageText: string;
 33+  planId: string;
 34+  platform: string;
 35+}
 36+
 37+export interface FirefoxSendMessageCommandInput extends FirefoxBridgeCommandTarget {
 38+  conversationId?: string | null;
 39+  planId: string;
 40+  platform: string;
 41+}
 42+
 43 export interface FirefoxRequestCancelCommandInput extends FirefoxBridgeCommandTarget {
 44   platform?: string | null;
 45   reason?: string | null;
 46@@ -1388,6 +1419,60 @@ export class FirefoxBridgeService {
 47     );
 48   }
 49 
 50+  uploadArtifacts(
 51+    input: FirefoxUploadArtifactsCommandInput
 52+  ): FirefoxBridgeDispatchReceipt {
 53+    return this.broker.dispatch(
 54+      "browser.upload_artifacts",
 55+      {
 56+        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
 57+        manifest_id: input.manifestId,
 58+        plan_id: input.planId,
 59+        platform: input.platform,
 60+        uploads: input.uploads.map((upload) => ({
 61+          artifact_id: upload.artifactId,
 62+          download_url: upload.downloadUrl,
 63+          filename: upload.filename,
 64+          mime_type: upload.mimeType,
 65+          sha256: upload.sha256,
 66+          size_bytes: upload.sizeBytes
 67+        }))
 68+      },
 69+      input
 70+    );
 71+  }
 72+
 73+  injectMessage(
 74+    input: FirefoxInjectMessageCommandInput
 75+  ): BrowserBridgeActionDispatch {
 76+    return this.broker.dispatchWithActionResult(
 77+      "browser.inject_message",
 78+      compactRecord({
 79+        action: "inject_message",
 80+        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
 81+        message_text: input.messageText,
 82+        plan_id: input.planId,
 83+        platform: input.platform
 84+      }),
 85+      input
 86+    );
 87+  }
 88+
 89+  sendMessage(
 90+    input: FirefoxSendMessageCommandInput
 91+  ): BrowserBridgeActionDispatch {
 92+    return this.broker.dispatchWithActionResult(
 93+      "browser.send_message",
 94+      compactRecord({
 95+        action: "send_message",
 96+        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
 97+        plan_id: input.planId,
 98+        platform: input.platform
 99+      }),
100+      input
101+    );
102+  }
103+
104   reload(input: FirefoxReloadCommandInput = {}): BrowserBridgeActionDispatch {
105     return this.broker.dispatchWithActionResult(
106       "reload",
M apps/conductor-daemon/src/firefox-ws.ts
+73, -2
  1@@ -3,6 +3,7 @@ import type { IncomingMessage } from "node:http";
  2 import type { Socket } from "node:net";
  3 import type { ControlPlaneRepository } from "../../../packages/db/dist/index.js";
  4 
  5+import { BaaArtifactDeliveryBridge } from "./artifacts/upload-session.js";
  6 import {
  7   FirefoxBridgeService,
  8   FirefoxCommandBroker,
  9@@ -947,6 +948,7 @@ class FirefoxWebSocketConnection {
 10 export class ConductorFirefoxWebSocketServer {
 11   private readonly baseUrlLoader: () => string;
 12   private readonly bridgeService: FirefoxBridgeService;
 13+  private readonly deliveryBridge: BaaArtifactDeliveryBridge;
 14   private readonly instructionIngest: BaaLiveInstructionIngest | null;
 15   private readonly now: () => number;
 16   private readonly repository: ControlPlaneRepository;
 17@@ -970,6 +972,15 @@ export class ConductorFirefoxWebSocketServer {
 18       resolveClientById: (clientId) => this.getClientById(clientId)
 19     });
 20     this.bridgeService = new FirefoxBridgeService(commandBroker);
 21+    this.deliveryBridge = new BaaArtifactDeliveryBridge({
 22+      baseUrlLoader: this.baseUrlLoader,
 23+      bridge: this.bridgeService,
 24+      now: () => this.getNextTimestampMilliseconds(),
 25+      onChange: () => this.broadcastStateSnapshot("delivery_session", {
 26+        force: true
 27+      }),
 28+      outputDirLoader: () => this.snapshotLoader().paths.stateDir ?? this.snapshotLoader().paths.tmpDir ?? null
 29+    });
 30   }
 31 
 32   getUrl(): string | null {
 33@@ -980,6 +991,10 @@ export class ConductorFirefoxWebSocketServer {
 34     return this.bridgeService;
 35   }
 36 
 37+  getDeliveryBridge(): BaaArtifactDeliveryBridge {
 38+    return this.deliveryBridge;
 39+  }
 40+
 41   getNowMilliseconds(): number {
 42     return this.now() * 1000;
 43   }
 44@@ -1012,6 +1027,7 @@ export class ConductorFirefoxWebSocketServer {
 45     }
 46 
 47     this.bridgeService.stop();
 48+    this.deliveryBridge.stop();
 49 
 50     for (const connection of [...this.connections]) {
 51       connection.close(1001, "server shutdown");
 52@@ -1083,6 +1099,7 @@ export class ConductorFirefoxWebSocketServer {
 53       connectionId: connection.getConnectionId(),
 54       reason: closeInfo.reason
 55     });
 56+    this.deliveryBridge.handleConnectionClosed(connection.getConnectionId(), closeInfo.reason);
 57     void this.markClientLoginStatesStale(clientId);
 58 
 59     void this.broadcastStateSnapshot("disconnect", {
 60@@ -1145,6 +1162,9 @@ export class ConductorFirefoxWebSocketServer {
 61       case "browser.final_message":
 62         await this.handleBrowserFinalMessage(connection, message);
 63         return;
 64+      case "browser.upload_receipt":
 65+        await this.handleBrowserUploadReceipt(connection, message);
 66+        return;
 67       case "api_request":
 68         this.sendError(
 69           connection,
 70@@ -1209,6 +1229,7 @@ export class ConductorFirefoxWebSocketServer {
 71           "api_endpoints",
 72           "client_log",
 73           "browser.final_message",
 74+          "browser.upload_receipt",
 75           "api_response",
 76           "stream_open",
 77           "stream_event",
 78@@ -1219,6 +1240,9 @@ export class ConductorFirefoxWebSocketServer {
 79           "hello_ack",
 80           "state_snapshot",
 81           "action_result",
 82+          "browser.upload_artifacts",
 83+          "browser.inject_message",
 84+          "browser.send_message",
 85           "request_credentials",
 86           "open_tab",
 87           "plugin_status",
 88@@ -1564,7 +1588,7 @@ export class ConductorFirefoxWebSocketServer {
 89       return;
 90     }
 91 
 92-    await this.instructionIngest.ingestAssistantFinalMessage({
 93+    const ingestResult = await this.instructionIngest.ingestAssistantFinalMessage({
 94       assistantMessageId: finalMessage.assistant_message_id,
 95       conversationId: finalMessage.conversation_id,
 96       observedAt: finalMessage.observed_at,
 97@@ -1573,6 +1597,50 @@ export class ConductorFirefoxWebSocketServer {
 98       text: finalMessage.raw_text
 99     });
100     await this.broadcastStateSnapshot("instruction_ingest");
101+
102+    if (ingestResult.processResult == null) {
103+      return;
104+    }
105+
106+    try {
107+      await this.deliveryBridge.deliver({
108+        assistantMessageId: finalMessage.assistant_message_id,
109+        clientId: connection.getClientId(),
110+        connectionId: connection.getConnectionId(),
111+        conversationId: finalMessage.conversation_id,
112+        platform: finalMessage.platform,
113+        processResult: ingestResult.processResult
114+      });
115+    } catch {
116+      // delivery session state is already written back into browser snapshots
117+    }
118+  }
119+
120+  private async handleBrowserUploadReceipt(
121+    connection: FirefoxWebSocketConnection,
122+    message: Record<string, unknown>
123+  ): Promise<void> {
124+    const planId = readFirstString(message, ["plan_id", "planId"]);
125+
126+    if (planId == null) {
127+      this.sendError(connection, "invalid_message", "browser.upload_receipt requires a non-empty plan_id field.");
128+      return;
129+    }
130+
131+    if (!Array.isArray(message.receipts) || message.receipts.length === 0) {
132+      this.sendError(connection, "invalid_message", "browser.upload_receipt requires a non-empty receipts array.");
133+      return;
134+    }
135+
136+    const handled = this.deliveryBridge.handleUploadReceipt(connection.getConnectionId(), message);
137+
138+    if (!handled) {
139+      this.sendError(
140+        connection,
141+        "invalid_message",
142+        `browser.upload_receipt does not match an active delivery plan: ${planId}.`
143+      );
144+    }
145   }
146 
147   private handleApiResponse(
148@@ -1798,9 +1866,12 @@ export class ConductorFirefoxWebSocketServer {
149       active_connection_id: activeClient?.connectionId ?? null,
150       client_count: clients.length,
151       clients,
152+      delivery: this.deliveryBridge.getSnapshot(),
153       instruction_ingest: this.instructionIngest?.getSnapshot() ?? {
154         last_execute: null,
155-        last_ingest: null
156+        last_ingest: null,
157+        recent_executes: [],
158+        recent_ingests: []
159       },
160       ws_path: FIREFOX_WS_PATH,
161       ws_url: this.getUrl()
M apps/conductor-daemon/src/index.test.js
+175, -2
  1@@ -17,6 +17,9 @@ import {
  2   BrowserRequestPolicyController,
  3   ConductorDaemon,
  4   ConductorRuntime,
  5+  PersistentBaaInstructionDeduper,
  6+  PersistentBaaLiveInstructionMessageDeduper,
  7+  PersistentBaaLiveInstructionSnapshotStore,
  8   createFetchControlApiClient,
  9   extractBaaInstructionBlocks,
 10   handleConductorHttpRequest,
 11@@ -58,10 +61,10 @@ function createLeaseResult({
 12   };
 13 }
 14 
 15-async function createLocalApiFixture() {
 16+async function createLocalApiFixture(options = {}) {
 17   const sharedToken = "local-shared-token";
 18   const controlPlane = new ConductorLocalControlPlane({
 19-    databasePath: ":memory:"
 20+    databasePath: options.databasePath ?? ":memory:"
 21   });
 22   await controlPlane.initialize();
 23 
 24@@ -4350,6 +4353,176 @@ test("ConductorRuntime routes browser.final_message into live instruction ingest
 25   }
 26 });
 27 
 28+test("persistent live ingest survives restart and /v1/browser restores recent history from the journal", async () => {
 29+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-final-message-persist-"));
 30+  const databasePath = join(stateDir, "control-plane.sqlite");
 31+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-final-message-history-"));
 32+  const outputPath = join(hostOpsDir, "final-message-persist.txt");
 33+  const messageText = [
 34+    "```baa",
 35+    `@conductor::exec::{"command":"printf 'persisted-live\\n' >> final-message-persist.txt","cwd":${JSON.stringify(hostOpsDir)}}`,
 36+    "```"
 37+  ].join("\n");
 38+  let nowMs = 1_710_000_020_000;
 39+  let runtime = null;
 40+
 41+  const buildPersistentIngest = (repository, sharedToken, snapshot) =>
 42+    new BaaLiveInstructionIngest({
 43+      center: new BaaInstructionCenter({
 44+        deduper: new PersistentBaaInstructionDeduper(repository, () => nowMs),
 45+        localApiContext: {
 46+          fetchImpl: globalThis.fetch,
 47+          repository,
 48+          sharedToken,
 49+          snapshotLoader: () => snapshot
 50+        }
 51+      }),
 52+      historyLimit: 10,
 53+      messageDeduper: new PersistentBaaLiveInstructionMessageDeduper(repository, () => nowMs),
 54+      now: () => nowMs,
 55+      snapshotStore: new PersistentBaaLiveInstructionSnapshotStore(repository, 10)
 56+    });
 57+
 58+  let firstFixture = null;
 59+  let restartedControlPlane = null;
 60+
 61+  try {
 62+    firstFixture = await createLocalApiFixture({
 63+      databasePath
 64+    });
 65+    const firstIngest = buildPersistentIngest(
 66+      firstFixture.repository,
 67+      firstFixture.sharedToken,
 68+      firstFixture.snapshot
 69+    );
 70+
 71+    await firstIngest.initialize();
 72+    const firstPass = await firstIngest.ingestAssistantFinalMessage({
 73+      assistantMessageId: "msg-final-message-persist",
 74+      conversationId: null,
 75+      observedAt: 1_710_000_020_000,
 76+      platform: "chatgpt",
 77+      source: "browser.final_message",
 78+      text: messageText
 79+    });
 80+
 81+    assert.equal(firstPass.summary.status, "executed");
 82+    assert.equal(readFileSync(outputPath, "utf8"), "persisted-live\n");
 83+
 84+    firstFixture.controlPlane.close();
 85+    firstFixture = null;
 86+
 87+    restartedControlPlane = new ConductorLocalControlPlane({
 88+      databasePath
 89+    });
 90+    await restartedControlPlane.initialize();
 91+
 92+    const restartedIngest = buildPersistentIngest(
 93+      restartedControlPlane.repository,
 94+      "local-shared-token",
 95+      {
 96+        codexd: {
 97+          localApiBase: null
 98+        },
 99+        controlApi: {
100+          baseUrl: "https://control.example.test",
101+          firefoxWsUrl: "ws://127.0.0.1:4317/ws/firefox",
102+          hasSharedToken: true,
103+          localApiBase: "http://127.0.0.1:4317",
104+          usesPlaceholderToken: false
105+        },
106+        daemon: {
107+          currentLeaderId: "mini-main",
108+          currentTerm: 1,
109+          host: "mini",
110+          lastError: null,
111+          leaseExpiresAt: 130,
112+          leaseState: "leader",
113+          nodeId: "mini-main",
114+          role: "primary",
115+          schedulerEnabled: true
116+        },
117+        identity: "mini-main@mini(primary)",
118+        runtime: {
119+          pid: 123,
120+          started: true,
121+          startedAt: 100
122+        },
123+        warnings: []
124+      }
125+    );
126+
127+    await restartedIngest.initialize();
128+    assert.equal(restartedIngest.getSnapshot().recent_ingests[0].status, "executed");
129+    assert.equal(restartedIngest.getSnapshot().recent_executes[0].status, "executed");
130+
131+    nowMs = 1_710_000_020_500;
132+
133+    const replayPass = await restartedIngest.ingestAssistantFinalMessage({
134+      assistantMessageId: "msg-final-message-persist",
135+      conversationId: "conv-replayed",
136+      observedAt: 1_710_000_020_500,
137+      platform: "chatgpt",
138+      source: "browser.final_message",
139+      text: messageText
140+    });
141+
142+    assert.equal(replayPass.summary.status, "duplicate_message");
143+    assert.equal(readFileSync(outputPath, "utf8"), "persisted-live\n");
144+    assert.equal(restartedIngest.getSnapshot().recent_ingests[0].status, "duplicate_message");
145+    assert.equal(restartedIngest.getSnapshot().recent_ingests[1].status, "executed");
146+    assert.equal(restartedIngest.getSnapshot().recent_executes[0].status, "executed");
147+
148+    restartedControlPlane.close();
149+    restartedControlPlane = null;
150+
151+    runtime = new ConductorRuntime(
152+      {
153+        nodeId: "mini-main",
154+        host: "mini",
155+        role: "primary",
156+        controlApiBase: "https://control.example.test",
157+        localApiBase: "http://127.0.0.1:0",
158+        sharedToken: "replace-me",
159+        paths: {
160+          runsDir: "/tmp/runs",
161+          stateDir
162+        }
163+      },
164+      {
165+        autoStartLoops: false,
166+        now: () => 100
167+      }
168+    );
169+
170+    const runtimeSnapshot = await runtime.start();
171+    const browserStatus = await fetchJson(`${runtimeSnapshot.controlApi.localApiBase}/v1/browser`);
172+
173+    assert.equal(browserStatus.response.status, 200);
174+    assert.equal(browserStatus.payload.data.instruction_ingest.last_ingest.status, "duplicate_message");
175+    assert.equal(browserStatus.payload.data.instruction_ingest.last_execute.status, "executed");
176+    assert.equal(browserStatus.payload.data.instruction_ingest.recent_ingests[0].status, "duplicate_message");
177+    assert.equal(browserStatus.payload.data.instruction_ingest.recent_ingests[1].status, "executed");
178+    assert.equal(browserStatus.payload.data.instruction_ingest.recent_executes[0].status, "executed");
179+  } finally {
180+    restartedControlPlane?.close();
181+    firstFixture?.controlPlane.close();
182+
183+    if (runtime != null) {
184+      await runtime.stop();
185+    }
186+
187+    rmSync(stateDir, {
188+      force: true,
189+      recursive: true
190+    });
191+    rmSync(hostOpsDir, {
192+      force: true,
193+      recursive: true
194+    });
195+  }
196+});
197+
198 test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Firefox bridge", async () => {
199   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-browser-http-"));
200   const runtime = new ConductorRuntime(
M apps/conductor-daemon/src/index.ts
+42, -9
  1@@ -5,7 +5,10 @@ import {
  2   type ServerResponse
  3 } from "node:http";
  4 import type { AddressInfo } from "node:net";
  5-import type { ControlPlaneRepository } from "../../../packages/db/dist/index.js";
  6+import {
  7+  DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT,
  8+  type ControlPlaneRepository
  9+} from "../../../packages/db/dist/index.js";
 10 
 11 import {
 12   type ConductorHttpRequest,
 13@@ -19,8 +22,15 @@ import {
 14   BrowserRequestPolicyController,
 15   type BrowserRequestPolicyControllerOptions
 16 } from "./browser-request-policy.js";
 17+import type { BrowserBridgeController } from "./browser-types.js";
 18 import type { FirefoxBridgeService } from "./firefox-bridge.js";
 19 import { BaaLiveInstructionIngest } from "./instructions/ingest.js";
 20+import { BaaInstructionCenter } from "./instructions/loop.js";
 21+import {
 22+  PersistentBaaInstructionDeduper,
 23+  PersistentBaaLiveInstructionMessageDeduper,
 24+  PersistentBaaLiveInstructionSnapshotStore
 25+} from "./instructions/store.js";
 26 import { handleConductorHttpRequest as handleConductorLocalHttpRequest } from "./local-api.js";
 27 import { ConductorLocalControlPlane } from "./local-control-plane.js";
 28 
 29@@ -32,8 +42,11 @@ export {
 30   type FirefoxBridgeApiResponse,
 31   type FirefoxBridgeCommandTarget,
 32   type FirefoxBridgeDispatchReceipt,
 33+  type FirefoxInjectMessageCommandInput,
 34   type FirefoxOpenTabCommandInput,
 35   type FirefoxReloadCommandInput,
 36+  type FirefoxSendMessageCommandInput,
 37+  type FirefoxUploadArtifactsCommandInput,
 38   type FirefoxRequestCredentialsCommandInput
 39 } from "./firefox-bridge.js";
 40 export {
 41@@ -680,6 +693,7 @@ class ConductorLocalHttpServer {
 42   private readonly codexdLocalApiBase: string | null;
 43   private readonly fetchImpl: typeof fetch;
 44   private readonly firefoxWebSocketServer: ConductorFirefoxWebSocketServer;
 45+  private readonly instructionIngest: BaaLiveInstructionIngest;
 46   private readonly localApiBase: string;
 47   private readonly now: () => number;
 48   private readonly repository: ControlPlaneRepository;
 49@@ -710,17 +724,32 @@ class ConductorLocalHttpServer {
 50     this.snapshotLoader = snapshotLoader;
 51     this.version = version;
 52     this.resolvedBaseUrl = localApiBase;
 53+    const nowMs = () => this.now() * 1000;
 54+    const localApiContext = {
 55+      fetchImpl: this.fetchImpl,
 56+      now: this.now,
 57+      repository: this.repository,
 58+      sharedToken: this.sharedToken,
 59+      snapshotLoader: this.snapshotLoader,
 60+      version: this.version
 61+    };
 62     const instructionIngest = new BaaLiveInstructionIngest({
 63+      center: new BaaInstructionCenter({
 64+        deduper: new PersistentBaaInstructionDeduper(this.repository, nowMs),
 65+        localApiContext
 66+      }),
 67+      historyLimit: DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT,
 68       localApiContext: {
 69-        fetchImpl: this.fetchImpl,
 70-        now: this.now,
 71-        repository: this.repository,
 72-        sharedToken: this.sharedToken,
 73-        snapshotLoader: this.snapshotLoader,
 74-        version: this.version
 75+        ...localApiContext
 76       },
 77-      now: () => this.now() * 1000
 78+      messageDeduper: new PersistentBaaLiveInstructionMessageDeduper(this.repository, nowMs),
 79+      now: nowMs,
 80+      snapshotStore: new PersistentBaaLiveInstructionSnapshotStore(
 81+        this.repository,
 82+        DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT
 83+      )
 84     });
 85+    this.instructionIngest = instructionIngest;
 86     this.firefoxWebSocketServer = new ConductorFirefoxWebSocketServer({
 87       baseUrlLoader: () => this.resolvedBaseUrl,
 88       instructionIngest,
 89@@ -747,6 +776,8 @@ class ConductorLocalHttpServer {
 90       return this.resolvedBaseUrl;
 91     }
 92 
 93+    await this.instructionIngest.initialize();
 94+
 95     const listenConfig = resolveLocalApiListenConfig(this.localApiBase);
 96     const server = createServer((request, response) => {
 97       void (async () => {
 98@@ -774,7 +805,9 @@ class ConductorLocalHttpServer {
 99             signal: requestAbortController.signal
100           },
101           {
102-            browserBridge: this.firefoxWebSocketServer.getBridgeService(),
103+            artifactDelivery: this.firefoxWebSocketServer.getDeliveryBridge(),
104+            browserBridge:
105+              this.firefoxWebSocketServer.getBridgeService() as unknown as BrowserBridgeController,
106             browserRequestPolicy: this.browserRequestPolicy,
107             browserStateLoader: () => this.firefoxWebSocketServer.getStateSnapshot(),
108             codexdLocalApiBase: this.codexdLocalApiBase,
M apps/conductor-daemon/src/instructions/index.ts
+1, -0
1@@ -8,3 +8,4 @@ export * from "./router.js";
2 export * from "./executor.js";
3 export * from "./loop.js";
4 export * from "./ingest.js";
5+export * from "./store.js";
M apps/conductor-daemon/src/instructions/ingest.ts
+109, -10
  1@@ -7,6 +7,7 @@ import {
  2   BaaInstructionCenterError,
  3   type BaaInstructionCenterOptions
  4 } from "./loop.js";
  5+import type { BaaLiveInstructionSnapshotStore } from "./store.js";
  6 import type { BaaInstructionProcessResult, BaaInstructionProcessStatus } from "./types.js";
  7 import { stableStringifyBaaJson } from "./types.js";
  8 
  9@@ -52,6 +53,8 @@ export interface BaaLiveInstructionIngestSummary {
 10 export interface BaaLiveInstructionIngestSnapshot {
 11   last_execute: BaaLiveInstructionIngestSummary | null;
 12   last_ingest: BaaLiveInstructionIngestSummary | null;
 13+  recent_executes: BaaLiveInstructionIngestSummary[];
 14+  recent_ingests: BaaLiveInstructionIngestSummary[];
 15 }
 16 
 17 export interface BaaLiveInstructionIngestResult {
 18@@ -60,15 +63,27 @@ export interface BaaLiveInstructionIngestResult {
 19 }
 20 
 21 export interface BaaLiveInstructionMessageDeduper {
 22-  add(dedupeKey: string): Promise<void> | void;
 23+  add(
 24+    dedupeKey: string,
 25+    metadata?: Pick<
 26+      BaaLiveInstructionIngestSummary,
 27+      | "assistant_message_id"
 28+      | "conversation_id"
 29+      | "ingested_at"
 30+      | "observed_at"
 31+      | "platform"
 32+    >
 33+  ): Promise<void> | void;
 34   has(dedupeKey: string): Promise<boolean> | boolean;
 35 }
 36 
 37 export interface BaaLiveInstructionIngestOptions {
 38   center?: BaaInstructionCenter;
 39+  historyLimit?: number;
 40   localApiContext?: ConductorLocalApiContext;
 41   messageDeduper?: BaaLiveInstructionMessageDeduper;
 42   now?: () => number;
 43+  snapshotStore?: BaaLiveInstructionSnapshotStore | null;
 44 }
 45 
 46 export class InMemoryBaaLiveInstructionMessageDeduper implements BaaLiveInstructionMessageDeduper {
 47@@ -115,6 +130,30 @@ function normalizeOptionalString(value: string | null | undefined): string | nul
 48   return normalized === "" ? null : normalized;
 49 }
 50 
 51+function cloneSummary(summary: BaaLiveInstructionIngestSummary): BaaLiveInstructionIngestSummary {
 52+  return {
 53+    ...summary,
 54+    duplicate_tools: [...summary.duplicate_tools],
 55+    executed_tools: [...summary.executed_tools],
 56+    instruction_tools: [...summary.instruction_tools]
 57+  };
 58+}
 59+
 60+function cloneSummaryList(
 61+  summaries: readonly BaaLiveInstructionIngestSummary[]
 62+): BaaLiveInstructionIngestSummary[] {
 63+  return summaries.map((summary) => cloneSummary(summary));
 64+}
 65+
 66+function normalizeHistoryLimit(limit: number | null | undefined): number {
 67+  if (typeof limit !== "number" || !Number.isFinite(limit)) {
 68+    return 20;
 69+  }
 70+
 71+  const normalized = Math.trunc(limit);
 72+  return normalized > 0 ? normalized : 20;
 73+}
 74+
 75 export function buildBaaLiveInstructionMessageDedupeKey(input: {
 76   assistantMessageId: string;
 77   platform: string;
 78@@ -134,10 +173,15 @@ export function buildBaaLiveInstructionMessageDedupeKey(input: {
 79 
 80 export class BaaLiveInstructionIngest {
 81   private readonly center: BaaInstructionCenter;
 82+  private readonly historyLimit: number;
 83   private readonly messageDeduper: BaaLiveInstructionMessageDeduper;
 84   private readonly now: () => number;
 85+  private readonly snapshotStore: BaaLiveInstructionSnapshotStore | null;
 86   private lastExecute: BaaLiveInstructionIngestSummary | null = null;
 87   private lastIngest: BaaLiveInstructionIngestSummary | null = null;
 88+  private recentExecutes: BaaLiveInstructionIngestSummary[] = [];
 89+  private recentIngests: BaaLiveInstructionIngestSummary[] = [];
 90+  private initializedSnapshotPromise: Promise<void> | null = null;
 91   private readonly pendingKeys = new Set<string>();
 92 
 93   constructor(options: BaaLiveInstructionIngestOptions) {
 94@@ -150,20 +194,38 @@ export class BaaLiveInstructionIngest {
 95       ?? new BaaInstructionCenter({
 96         localApiContext: options.localApiContext as BaaInstructionCenterOptions["localApiContext"]
 97       });
 98+    this.historyLimit = normalizeHistoryLimit(options.historyLimit);
 99     this.messageDeduper = options.messageDeduper ?? new InMemoryBaaLiveInstructionMessageDeduper();
100     this.now = options.now ?? Date.now;
101+    this.snapshotStore = options.snapshotStore ?? null;
102   }
103 
104   getSnapshot(): BaaLiveInstructionIngestSnapshot {
105     return {
106-      last_execute: this.lastExecute == null ? null : { ...this.lastExecute },
107-      last_ingest: this.lastIngest == null ? null : { ...this.lastIngest }
108+      last_execute: this.lastExecute == null ? null : cloneSummary(this.lastExecute),
109+      last_ingest: this.lastIngest == null ? null : cloneSummary(this.lastIngest),
110+      recent_executes: cloneSummaryList(this.recentExecutes),
111+      recent_ingests: cloneSummaryList(this.recentIngests)
112     };
113   }
114 
115+  async initialize(): Promise<void> {
116+    if (this.snapshotStore == null) {
117+      return;
118+    }
119+
120+    if (this.initializedSnapshotPromise == null) {
121+      this.initializedSnapshotPromise = this.loadPersistedSnapshot();
122+    }
123+
124+    await this.initializedSnapshotPromise;
125+  }
126+
127   async ingestAssistantFinalMessage(
128     input: BaaLiveInstructionIngestInput
129   ): Promise<BaaLiveInstructionIngestResult> {
130+    await this.initialize();
131+
132     const messageDedupeKey = buildBaaLiveInstructionMessageDedupeKey({
133       assistantMessageId: input.assistantMessageId,
134       platform: input.platform,
135@@ -196,7 +258,7 @@ export class BaaLiveInstructionIngest {
136         instruction_tools: [],
137         status: "duplicate_message"
138       };
139-      this.lastIngest = summary;
140+      await this.publishSummary("ingest", summary);
141       return {
142         processResult: null,
143         summary
144@@ -214,11 +276,11 @@ export class BaaLiveInstructionIngest {
145       });
146       const summary = this.buildSuccessSummary(baseSummary, processResult);
147 
148-      await this.messageDeduper.add(messageDedupeKey);
149-      this.lastIngest = summary;
150+      await this.messageDeduper.add(messageDedupeKey, summary);
151+      await this.publishSummary("ingest", summary);
152 
153       if (shouldUpdateExecutionSummary(summary.status)) {
154-        this.lastExecute = summary;
155+        await this.publishSummary("execute", summary);
156       }
157 
158       return {
159@@ -229,11 +291,11 @@ export class BaaLiveInstructionIngest {
160       const summary = this.buildFailureSummary(baseSummary, error);
161 
162       if (error instanceof BaaInstructionCenterError) {
163-        await this.messageDeduper.add(messageDedupeKey);
164+        await this.messageDeduper.add(messageDedupeKey, summary);
165       }
166 
167-      this.lastIngest = summary;
168-      this.lastExecute = summary;
169+      await this.publishSummary("ingest", summary);
170+      await this.publishSummary("execute", summary);
171 
172       return {
173         processResult: null,
174@@ -244,6 +306,43 @@ export class BaaLiveInstructionIngest {
175     }
176   }
177 
178+  private async loadPersistedSnapshot(): Promise<void> {
179+    if (this.snapshotStore == null) {
180+      return;
181+    }
182+
183+    const snapshot = await this.snapshotStore.loadSnapshot(this.historyLimit);
184+    this.lastExecute = snapshot.last_execute == null ? null : cloneSummary(snapshot.last_execute);
185+    this.lastIngest = snapshot.last_ingest == null ? null : cloneSummary(snapshot.last_ingest);
186+    this.recentExecutes = cloneSummaryList(snapshot.recent_executes).slice(0, this.historyLimit);
187+    this.recentIngests = cloneSummaryList(snapshot.recent_ingests).slice(0, this.historyLimit);
188+  }
189+
190+  private async publishSummary(
191+    kind: "execute" | "ingest",
192+    summary: BaaLiveInstructionIngestSummary
193+  ): Promise<void> {
194+    if (this.snapshotStore != null) {
195+      await this.snapshotStore.appendSummary(kind, summary);
196+    }
197+
198+    if (kind === "execute") {
199+      this.lastExecute = cloneSummary(summary);
200+      this.recentExecutes = this.pushRecent(this.recentExecutes, summary);
201+      return;
202+    }
203+
204+    this.lastIngest = cloneSummary(summary);
205+    this.recentIngests = this.pushRecent(this.recentIngests, summary);
206+  }
207+
208+  private pushRecent(
209+    summaries: readonly BaaLiveInstructionIngestSummary[],
210+    summary: BaaLiveInstructionIngestSummary
211+  ): BaaLiveInstructionIngestSummary[] {
212+    return [cloneSummary(summary), ...cloneSummaryList(summaries)].slice(0, this.historyLimit);
213+  }
214+
215   private buildFailureSummary(
216     baseSummary: Pick<
217       BaaLiveInstructionIngestSummary,
A apps/conductor-daemon/src/instructions/store.ts
+186, -0
  1@@ -0,0 +1,186 @@
  2+import {
  3+  DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT,
  4+  type ControlPlaneRepository
  5+} from "../../../../packages/db/dist/index.js";
  6+
  7+import type { BaaInstructionDeduper } from "./dedupe.js";
  8+import type {
  9+  BaaLiveInstructionIngestSnapshot,
 10+  BaaLiveInstructionIngestSummary,
 11+  BaaLiveInstructionMessageDeduper
 12+} from "./ingest.js";
 13+import type { BaaInstructionEnvelope } from "./types.js";
 14+
 15+function cloneSummary(summary: BaaLiveInstructionIngestSummary): BaaLiveInstructionIngestSummary {
 16+  return {
 17+    ...summary,
 18+    duplicate_tools: [...summary.duplicate_tools],
 19+    executed_tools: [...summary.executed_tools],
 20+    instruction_tools: [...summary.instruction_tools]
 21+  };
 22+}
 23+
 24+function isStringArray(value: unknown): value is string[] {
 25+  return Array.isArray(value) && value.every((entry) => typeof entry === "string");
 26+}
 27+
 28+function isBaaLiveInstructionIngestSummary(value: unknown): value is BaaLiveInstructionIngestSummary {
 29+  if (value == null || typeof value !== "object" || Array.isArray(value)) {
 30+    return false;
 31+  }
 32+
 33+  const summary = value as Record<string, unknown>;
 34+  return (
 35+    typeof summary.assistant_message_id === "string"
 36+    && typeof summary.block_count === "number"
 37+    && (summary.conversation_id == null || typeof summary.conversation_id === "string")
 38+    && typeof summary.duplicate_instruction_count === "number"
 39+    && isStringArray(summary.duplicate_tools)
 40+    && (summary.error_block_index == null || typeof summary.error_block_index === "number")
 41+    && (summary.error_message == null || typeof summary.error_message === "string")
 42+    && (summary.error_stage == null || typeof summary.error_stage === "string")
 43+    && isStringArray(summary.executed_tools)
 44+    && typeof summary.execution_count === "number"
 45+    && typeof summary.execution_failed_count === "number"
 46+    && typeof summary.execution_ok_count === "number"
 47+    && typeof summary.ingested_at === "number"
 48+    && typeof summary.instruction_count === "number"
 49+    && isStringArray(summary.instruction_tools)
 50+    && typeof summary.message_dedupe_key === "string"
 51+    && (summary.observed_at == null || typeof summary.observed_at === "number")
 52+    && typeof summary.platform === "string"
 53+    && summary.source === "browser.final_message"
 54+    && typeof summary.status === "string"
 55+  );
 56+}
 57+
 58+function parseSummary(summaryJson: string): BaaLiveInstructionIngestSummary | null {
 59+  try {
 60+    const parsed = JSON.parse(summaryJson) as unknown;
 61+    return isBaaLiveInstructionIngestSummary(parsed) ? cloneSummary(parsed) : null;
 62+  } catch {
 63+    return null;
 64+  }
 65+}
 66+
 67+export interface BaaLiveInstructionSnapshotStore {
 68+  appendSummary(kind: "execute" | "ingest", summary: BaaLiveInstructionIngestSummary): Promise<void>;
 69+  loadSnapshot(limit?: number): Promise<BaaLiveInstructionIngestSnapshot>;
 70+}
 71+
 72+export class PersistentBaaInstructionDeduper implements BaaInstructionDeduper {
 73+  constructor(
 74+    private readonly repository: ControlPlaneRepository,
 75+    private readonly now: () => number = Date.now
 76+  ) {}
 77+
 78+  async add(instruction: BaaInstructionEnvelope): Promise<void> {
 79+    await this.repository.putBaaInstructionDedupe({
 80+      assistantMessageId: instruction.assistantMessageId,
 81+      conversationId: instruction.conversationId,
 82+      createdAt: this.now(),
 83+      dedupeKey: instruction.dedupeKey,
 84+      instructionId: instruction.instructionId,
 85+      platform: instruction.platform,
 86+      target: instruction.target,
 87+      tool: instruction.tool
 88+    });
 89+  }
 90+
 91+  async has(dedupeKey: string): Promise<boolean> {
 92+    return this.repository.hasBaaInstructionDedupe(dedupeKey);
 93+  }
 94+}
 95+
 96+export class PersistentBaaLiveInstructionMessageDeduper
 97+  implements BaaLiveInstructionMessageDeduper
 98+{
 99+  constructor(
100+    private readonly repository: ControlPlaneRepository,
101+    private readonly now: () => number = Date.now
102+  ) {}
103+
104+  async add(
105+    dedupeKey: string,
106+    metadata?: Pick<
107+      BaaLiveInstructionIngestSummary,
108+      | "assistant_message_id"
109+      | "conversation_id"
110+      | "ingested_at"
111+      | "observed_at"
112+      | "platform"
113+    >
114+  ): Promise<void> {
115+    await this.repository.putBaaMessageDedupe({
116+      assistantMessageId: metadata?.assistant_message_id ?? dedupeKey,
117+      conversationId: metadata?.conversation_id ?? null,
118+      createdAt: metadata?.ingested_at ?? this.now(),
119+      dedupeKey,
120+      observedAt: metadata?.observed_at ?? null,
121+      platform: metadata?.platform ?? "unknown"
122+    });
123+  }
124+
125+  async has(dedupeKey: string): Promise<boolean> {
126+    return this.repository.hasBaaMessageDedupe(dedupeKey);
127+  }
128+}
129+
130+export class PersistentBaaLiveInstructionSnapshotStore implements BaaLiveInstructionSnapshotStore {
131+  private readonly historyLimit: number;
132+
133+  constructor(
134+    private readonly repository: ControlPlaneRepository,
135+    historyLimit: number = DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT
136+  ) {
137+    this.historyLimit = Number.isFinite(historyLimit) && historyLimit > 0
138+      ? Math.trunc(historyLimit)
139+      : DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT;
140+  }
141+
142+  async appendSummary(
143+    kind: "execute" | "ingest",
144+    summary: BaaLiveInstructionIngestSummary
145+  ): Promise<void> {
146+    await this.repository.appendBaaExecutionJournal({
147+      assistantMessageId: summary.assistant_message_id,
148+      conversationId: summary.conversation_id,
149+      ingestedAt: summary.ingested_at,
150+      kind,
151+      messageDedupeKey: summary.message_dedupe_key,
152+      observedAt: summary.observed_at,
153+      platform: summary.platform,
154+      source: summary.source,
155+      status: summary.status,
156+      summaryJson: JSON.stringify(cloneSummary(summary))
157+    });
158+  }
159+
160+  async loadSnapshot(limit: number = this.historyLimit): Promise<BaaLiveInstructionIngestSnapshot> {
161+    const normalizedLimit =
162+      Number.isFinite(limit) && limit > 0 ? Math.trunc(limit) : this.historyLimit;
163+    const [ingestRows, executeRows] = await Promise.all([
164+      this.repository.listBaaExecutionJournal({
165+        kind: "ingest",
166+        limit: normalizedLimit
167+      }),
168+      this.repository.listBaaExecutionJournal({
169+        kind: "execute",
170+        limit: normalizedLimit
171+      })
172+    ]);
173+    const recentIngests = ingestRows
174+      .map((row) => parseSummary(row.summaryJson))
175+      .filter((summary): summary is BaaLiveInstructionIngestSummary => summary != null);
176+    const recentExecutes = executeRows
177+      .map((row) => parseSummary(row.summaryJson))
178+      .filter((summary): summary is BaaLiveInstructionIngestSummary => summary != null);
179+
180+    return {
181+      last_execute: recentExecutes[0] ?? null,
182+      last_ingest: recentIngests[0] ?? null,
183+      recent_executes: recentExecutes,
184+      recent_ingests: recentIngests
185+    };
186+  }
187+}
M apps/conductor-daemon/src/local-api.ts
+135, -2
  1@@ -60,6 +60,7 @@ import {
  2   type BrowserRequestAdmission,
  3   type BrowserRequestPolicyLease
  4 } from "./browser-request-policy.js";
  5+import type { BaaArtifactDeliveryBridge } from "./artifacts/upload-session.js";
  6 
  7 const DEFAULT_LIST_LIMIT = 20;
  8 const DEFAULT_LOG_LIMIT = 200;
  9@@ -185,6 +186,7 @@ type UpstreamErrorEnvelope = JsonObject & {
 10 };
 11 
 12 interface LocalApiRequestContext {
 13+  artifactDelivery: BaaArtifactDeliveryBridge | null;
 14   browserBridge: BrowserBridgeController | null;
 15   browserRequestPolicy: BrowserRequestPolicyController | null;
 16   browserStateLoader: () => BrowserBridgeStateSnapshot | null;
 17@@ -232,6 +234,7 @@ export interface ConductorRuntimeApiSnapshot {
 18 }
 19 
 20 export interface ConductorLocalApiContext {
 21+  artifactDelivery?: BaaArtifactDeliveryBridge | null;
 22   browserBridge?: BrowserBridgeController | null;
 23   browserRequestPolicy?: BrowserRequestPolicyController | null;
 24   browserStateLoader?: (() => BrowserBridgeStateSnapshot | null) | null;
 25@@ -394,6 +397,14 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
 26     pathPattern: "/v1/browser/request/cancel",
 27     summary: "取消通用 browser 请求或流"
 28   },
 29+  {
 30+    id: "browser.delivery.artifact",
 31+    exposeInDescribe: false,
 32+    kind: "read",
 33+    method: "GET",
 34+    pathPattern: "/v1/browser/delivery/artifacts/:plan_id/:artifact_id",
 35+    summary: "插件 delivery 内部读取 artifact payload"
 36+  },
 37   {
 38     id: "browser.claude.open",
 39     kind: "write",
 40@@ -1633,6 +1644,10 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
 41     active_connection_id: null,
 42     client_count: 0,
 43     clients: [],
 44+    delivery: {
 45+      activeSessionCount: 0,
 46+      lastSession: null
 47+    },
 48     instruction_ingest: createEmptyBrowserInstructionIngestSnapshot(),
 49     ws_path: "/ws/firefox",
 50     ws_url: snapshot.controlApi.firefoxWsUrl ?? null
 51@@ -1642,13 +1657,19 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
 52 function createEmptyBrowserInstructionIngestSnapshot(): BrowserBridgeStateSnapshot["instruction_ingest"] {
 53   return {
 54     last_execute: null,
 55-    last_ingest: null
 56+    last_ingest: null,
 57+    recent_executes: [],
 58+    recent_ingests: []
 59   };
 60 }
 61 
 62 function normalizeBrowserStateSnapshot(state: BrowserBridgeStateSnapshot): BrowserBridgeStateSnapshot {
 63   return {
 64     ...state,
 65+    delivery: state.delivery ?? {
 66+      activeSessionCount: 0,
 67+      lastSession: null
 68+    },
 69     instruction_ingest: state.instruction_ingest ?? createEmptyBrowserInstructionIngestSnapshot()
 70   };
 71 }
 72@@ -2092,6 +2113,18 @@ function requireBrowserBridge(context: LocalApiRequestContext): BrowserBridgeCon
 73   return context.browserBridge;
 74 }
 75 
 76+function requireArtifactDelivery(context: LocalApiRequestContext): BaaArtifactDeliveryBridge {
 77+  if (context.artifactDelivery == null) {
 78+    throw new LocalApiHttpError(
 79+      503,
 80+      "browser_delivery_unavailable",
 81+      "Firefox artifact delivery bridge is not configured on this conductor runtime."
 82+    );
 83+  }
 84+
 85+  return context.artifactDelivery;
 86+}
 87+
 88 function resolveBrowserRequestPolicy(context: LocalApiRequestContext): BrowserRequestPolicyController {
 89   return context.browserRequestPolicy ?? new BrowserRequestPolicyController({
 90     config: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG
 91@@ -2336,10 +2369,71 @@ function serializeBrowserInstructionIngestSnapshot(
 92 
 93   return {
 94     last_execute: serializeBrowserInstructionIngestSummary(normalized.last_execute),
 95-    last_ingest: serializeBrowserInstructionIngestSummary(normalized.last_ingest)
 96+    last_ingest: serializeBrowserInstructionIngestSummary(normalized.last_ingest),
 97+    recent_executes: normalized.recent_executes.map((summary) =>
 98+      serializeBrowserInstructionIngestSummary(summary)
 99+    ),
100+    recent_ingests: normalized.recent_ingests.map((summary) =>
101+      serializeBrowserInstructionIngestSummary(summary)
102+    )
103   };
104 }
105 
106+function serializeBrowserDeliverySnapshot(
107+  snapshot: BrowserBridgeStateSnapshot["delivery"] | undefined
108+): JsonObject {
109+  const normalized = snapshot ?? {
110+    activeSessionCount: 0,
111+    lastSession: null
112+  };
113+
114+  return compactJsonObject({
115+    active_session_count: normalized.activeSessionCount,
116+    last_session:
117+      normalized.lastSession == null
118+        ? null
119+        : compactJsonObject({
120+            auto_send: normalized.lastSession.autoSend,
121+            client_id: normalized.lastSession.clientId ?? undefined,
122+            completed_at: normalized.lastSession.completedAt ?? undefined,
123+            connection_id: normalized.lastSession.connectionId ?? undefined,
124+            conversation_id: normalized.lastSession.conversationId ?? undefined,
125+            created_at: normalized.lastSession.createdAt,
126+            failed_at: normalized.lastSession.failedAt ?? undefined,
127+            failed_reason: normalized.lastSession.failedReason ?? undefined,
128+            inject_completed_at: normalized.lastSession.injectCompletedAt ?? undefined,
129+            inject_request_id: normalized.lastSession.injectRequestId ?? undefined,
130+            inject_started_at: normalized.lastSession.injectStartedAt ?? undefined,
131+            manifest_id: normalized.lastSession.manifestId,
132+            pending_upload_artifact_ids: [...normalized.lastSession.pendingUploadArtifactIds],
133+            plan_id: normalized.lastSession.planId,
134+            platform: normalized.lastSession.platform,
135+            receipt_confirmed_count: normalized.lastSession.receiptConfirmedCount,
136+            round_id: normalized.lastSession.roundId,
137+            send_completed_at: normalized.lastSession.sendCompletedAt ?? undefined,
138+            send_request_id: normalized.lastSession.sendRequestId ?? undefined,
139+            send_started_at: normalized.lastSession.sendStartedAt ?? undefined,
140+            stage: normalized.lastSession.stage,
141+            trace_id: normalized.lastSession.traceId,
142+            upload_count: normalized.lastSession.uploadCount,
143+            upload_dispatched_at: normalized.lastSession.uploadDispatchedAt ?? undefined,
144+            upload_receipts: normalized.lastSession.uploadReceipts.map((receipt) =>
145+              compactJsonObject({
146+                artifact_id: receipt.artifactId,
147+                attempts: receipt.attempts,
148+                error: receipt.error ?? undefined,
149+                filename: receipt.filename,
150+                ok: receipt.ok,
151+                received_at: receipt.receivedAt ?? undefined,
152+                remote_handle: receipt.remoteHandle ?? undefined,
153+                sha256: receipt.sha256,
154+                size_bytes: receipt.sizeBytes
155+              })
156+            )
157+          })
158+  });
159+}
160+
161 function serializeBrowserClientSnapshot(snapshot: BrowserBridgeClientSnapshot): JsonObject {
162   return {
163     client_id: snapshot.client_id,
164@@ -2933,6 +3027,7 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
165       ws_path: browserState.ws_path,
166       ws_url: browserState.ws_url
167     },
168+    delivery: serializeBrowserDeliverySnapshot(browserState.delivery),
169     instruction_ingest: serializeBrowserInstructionIngestSnapshot(browserState.instruction_ingest),
170     current_client: currentClient == null ? null : serializeBrowserClientSnapshot(currentClient),
171     claude: {
172@@ -3231,6 +3326,7 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
173       "api_endpoints",
174       "client_log",
175       "browser.final_message",
176+      "browser.upload_receipt",
177       "api_response",
178       "stream_open",
179       "stream_event",
180@@ -3241,6 +3337,9 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
181       "hello_ack",
182       "state_snapshot",
183       "action_result",
184+      "browser.upload_artifacts",
185+      "browser.inject_message",
186+      "browser.send_message",
187       "open_tab",
188       "plugin_status",
189       "ws_reconnect",
190@@ -5070,6 +5169,37 @@ async function handleBrowserRequestCancel(context: LocalApiRequestContext): Prom
191   });
192 }
193 
194+async function handleBrowserDeliveryArtifact(
195+  context: LocalApiRequestContext
196+): Promise<ConductorHttpResponse> {
197+  const planId = normalizeOptionalString(context.params.plan_id);
198+  const artifactId = normalizeOptionalString(context.params.artifact_id);
199+
200+  if (planId == null || artifactId == null) {
201+    throw new LocalApiHttpError(
202+      400,
203+      "invalid_request",
204+      "Artifact delivery route requires non-empty plan_id and artifact_id params."
205+    );
206+  }
207+
208+  const payload = await requireArtifactDelivery(context).readArtifactContent(planId, artifactId);
209+
210+  if (payload == null) {
211+    throw new LocalApiHttpError(
212+      404,
213+      "artifact_not_found",
214+      `No active delivery artifact matches plan "${planId}" and artifact "${artifactId}".`,
215+      {
216+        artifact_id: artifactId,
217+        plan_id: planId
218+      }
219+    );
220+  }
221+
222+  return buildSuccessEnvelope(context.requestId, 200, payload as unknown as JsonValue);
223+}
224+
225 async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
226   const body = readBodyObject(context.request, true);
227   const dispatch = await dispatchBrowserAction(context, {
228@@ -5582,6 +5712,8 @@ async function dispatchBusinessRoute(
229       return handleBrowserRequest(context);
230     case "browser.request.cancel":
231       return handleBrowserRequestCancel(context);
232+    case "browser.delivery.artifact":
233+      return handleBrowserDeliveryArtifact(context);
234     case "browser.claude.open":
235       return handleBrowserClaudeOpen(context);
236     case "browser.claude.send":
237@@ -5766,6 +5898,7 @@ export async function handleConductorHttpRequest(
238     return await dispatchRoute(
239       matchedRoute,
240       {
241+        artifactDelivery: context.artifactDelivery ?? null,
242         browserBridge: context.browserBridge ?? null,
243         browserRequestPolicy: context.browserRequestPolicy ?? null,
244         browserStateLoader: context.browserStateLoader ?? (() => null),
M apps/conductor-daemon/src/node-shims.d.ts
+11, -0
 1@@ -65,6 +65,13 @@ declare module "node:fs/promises" {
 2     options?: MakeDirectoryOptions
 3   ): Promise<string | undefined>;
 4 
 5+  export function readFile(path: string): Promise<Buffer>;
 6+
 7+  export function readFile(
 8+    path: string,
 9+    options: FileOperationOptions | string
10+  ): Promise<string | Buffer>;
11+
12   export function writeFile(
13     path: string,
14     data: string,
15@@ -117,6 +124,10 @@ declare module "node:path" {
16   export function resolve(...paths: string[]): string;
17 }
18 
19+declare module "node:os" {
20+  export function tmpdir(): string;
21+}
22+
23 declare module "node:url" {
24   export function fileURLToPath(url: string | URL): string;
25 }
M bugs/README.md
+24, -53
 1@@ -1,70 +1,41 @@
 2 # bugs
 3 
 4-当前目录只保留:
 5+当前目录只保留 open bug 和模板。已关闭的 bug、修复任务卡和优化建议归档在 `archive/` 子目录。
 6 
 7-- 当前仍需保留记录的 bug
 8-- 一个通用 `BUG-TEMPLATE.md`
 9-- 对应 bug 的修复任务卡
10-- 优化建议(OPT-XXX)
11+## 目录结构
12 
13-## 已关闭
14-
15-1. `BUG-008` — 已随 `BUG-010` 一并修复
16-2. `BUG-009` — 已修复
17-3. `BUG-010` — 已修复
18-4. `BUG-011` — 已修复;`writeHttpResponse()` 在 body / stream 背压路径下都会等待 `drain` / `close` / `error`,不再永久挂起
19-5. `BUG-012` — 已修复;browser request policy waiter 现在会超时退出并返回明确错误,不再永久挂起
20-6. `BUG-013` — 已修复;stream session 结束后会清理 timer,并已有 broker 级回归测试确认不会再触发 timeout / cancel
21-7. `BUG-014` — 已修复;`ws_reconnect` 现改为 deferred 结果,`action_result.completed` 不再提前为 `true`
22-8. `BUG-015` — 按当前代码核对,SSE 流式回传主链路已落地,不再作为“插件缺少 SSE 实现”的 open bug 保留
23-9. `BUG-016` — 按当前代码核对,自定义 headers 已进入 bridge -> plugin -> page fetch 链路,不再作为“headers 完全未透传”的 open bug 保留
24-10. `BUG-017` — 已修复;buffered 模式收到 SSE 原始文本时,conductor 现在会解析成结构化 `events` / `full_text`
25+```
26+bugs/
27+  README.md          ← 本文件
28+  BUG-TEMPLATE.md    ← 新 bug 模板
29+  archive/           ← 已关闭的 BUG-*.md、FIX-*.md、OPT-*.md
30+```
31 
32 ## 待修复
33 
34 - 当前 open bug backlog:无
35 
36-## 当前代码核对结论(2026-03-27)
37-
38-- `BUG-011` 已修复:
39-  - `apps/conductor-daemon/src/index.ts` 已新增 `awaitWritableDrainOrClose(...)`
40-  - `payload.body` 和 `streamBody` 两条背压等待路径都会同时处理 `drain` / `close` / `error`
41-  - `apps/conductor-daemon/src/index.test.js` 已补 body close、stream close 和 drain 继续写入 3 条测试
42-- `BUG-012` 已修复:
43-  - `apps/conductor-daemon/src/browser-request-policy.ts` 已为 target slot / platform admission waiter 增加超时与队列清理
44-  - `apps/conductor-daemon/src/index.test.js` 已覆盖 target_slot timeout、platform_admission timeout 和 HTTP 503 返回路径
45-  - 剩余风险:这轮修复解决的是“永久挂起”,不是“自动回收泄漏 slot”;如果某个 lease 持续泄漏且不恢复,同一 `target` 的后续请求会稳定超时失败,而不是自动自愈。如需继续增强,可后续补 stale `inFlight` 清扫机制
46-- `BUG-014` 已修复:
47-  - `plugins/baa-firefox/controller.js` 的 `ws_reconnect` 现在会返回 `deferred: true`
48-  - `sendPluginActionResult(...)` 会把 deferred 结果发送成 `completed: false`
49-  - `tests/browser/browser-control-e2e-smoke.test.mjs` 已覆盖 `plugin_status.completed === true` 和 `ws_reconnect.completed === false`
50-  - 剩余风险:当前自动化验证覆盖的是 conductor 侧端到端语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步,现有设计未扩改
51-- `BUG-013` 已修复:
52-  - `apps/conductor-daemon/src/firefox-bridge.ts` 当前代码已在 stream session `close()` 路径统一调用 `clearTimers()`
53-  - `apps/conductor-daemon/src/index.test.js` 已补 `FirefoxCommandBroker clears stream timers after the stream ends` 回归测试
54-- `BUG-017` 已修复:
55-  - `apps/conductor-daemon/src/local-api.ts` 现在会把 buffered 模式返回的原始 SSE 文本解析成结构化对象
56-  - 返回体会包含 `content_type: "text/event-stream"`、`events`、`full_text`、`raw`
57-  - `apps/conductor-daemon/src/index.test.js` 已补 buffered SSE 响应解析断言
58-- `BUG-015` 的“插件侧缺少 SSE 实现”结论与当前代码不一致:
59-  - `plugins/baa-firefox/controller.js` 已有 `response_mode === "sse"` 分支
60-  - `plugins/baa-firefox/page-interceptor.js` 已实现 `streamProxyResponse(...)`
61-  - 现有测试已覆盖 `stream_open` / `stream_event` / `stream_end`
62-- `BUG-016` 的“自定义 headers 完全未透传”结论与当前代码不一致:
63-  - `firefox-bridge.ts` 已把 `headers` 放入 `api_request`
64-  - `page-interceptor.js` 会把 `detail.headers` 合并进实际 `fetch(...)`
65-  - 同时保留 forbidden header 过滤
66-- 如果 `BUG-015` / `BUG-016` 仍在线上环境复现,应以新的复现条件重新开卡,不再沿用“缺少实现”这一版根因描述
67-
68-## 优化建议
69+## 已归档(archive/)
70 
71-| # | 文件 | 内容 |
72+| ID | 状态 | 一句话 |
73 |---|---|---|
74-| OPT-001 | `OPT-001-*.md` | action_result 命名风格、test 重复定义、async 错误路径注释 |
75+| BUG-008 | FIXED | codexd 第二个 session turn 超时 |
76+| BUG-009 | FIXED | 测试遗留 HTTP listener 挂起 |
77+| BUG-010 | FIXED | codexd turn 状态卡在 inProgress |
78+| BUG-011 | FIXED | writeHttpResponse drain handler 永久挂起 |
79+| BUG-012 | FIXED | browser-request-policy waiter 死锁 |
80+| BUG-013 | FIXED | stream session timer 未清除 |
81+| BUG-014 | FIXED | ws_reconnect 过早报 completed |
82+| BUG-015 | CLOSED | SSE 实现已存在,误报 |
83+| BUG-016 | CLOSED | headers 透传已存在,误报 |
84+| BUG-017 | FIXED | buffered SSE 返回原始文本 |
85+| OPT-001 | — | action_result 命名风格等代码质量建议 |
86+
87+详细的代码核对结论和剩余风险说明见各 `archive/BUG-*.md` 和 `archive/FIX-*.md`。
88 
89 ## 编号规则
90 
91 - BUG-XXX:bug 报告
92-- FIX-BUG-XXX:对应修复任务卡(给 Codex 执行)
93+- FIX-BUG-XXX:对应修复任务卡
94 - OPT-XXX:优化建议(非紧急)
95 - 编号按发现顺序递增,不复用
R bugs/BUG-008-codexd-second-thread-turn-timeout.md => bugs/archive/BUG-008-codexd-second-thread-turn-timeout.md
+0, -0
R bugs/BUG-009-conductor-daemon-index-test-leaks-local-listener.md => bugs/archive/BUG-009-conductor-daemon-index-test-leaks-local-listener.md
+0, -0
R bugs/BUG-010-codexd-turn-status-stuck-inprogress.md => bugs/archive/BUG-010-codexd-turn-status-stuck-inprogress.md
+0, -0
R bugs/BUG-011-writeHttpResponse-drain-handler-hangs.md => bugs/archive/BUG-011-writeHttpResponse-drain-handler-hangs.md
+0, -0
R bugs/BUG-012-browser-request-policy-waiter-deadlock.md => bugs/archive/BUG-012-browser-request-policy-waiter-deadlock.md
+0, -0
R bugs/BUG-013-stream-session-timer-not-cleared.md => bugs/archive/BUG-013-stream-session-timer-not-cleared.md
+0, -0
R bugs/BUG-014-ws-reconnect-premature-completed.md => bugs/archive/BUG-014-ws-reconnect-premature-completed.md
+0, -0
R bugs/BUG-015-sse-stream-open-timeout.md => bugs/archive/BUG-015-sse-stream-open-timeout.md
+0, -0
R bugs/BUG-016-custom-headers-not-forwarded.md => bugs/archive/BUG-016-custom-headers-not-forwarded.md
+0, -0
R bugs/BUG-017-buffered-sse-raw-text.md => bugs/archive/BUG-017-buffered-sse-raw-text.md
+0, -0
R bugs/FIX-BUG-008.md => bugs/archive/FIX-BUG-008.md
+0, -0
R bugs/FIX-BUG-011.md => bugs/archive/FIX-BUG-011.md
+0, -0
R bugs/FIX-BUG-012.md => bugs/archive/FIX-BUG-012.md
+0, -0
R bugs/FIX-BUG-013.md => bugs/archive/FIX-BUG-013.md
+0, -0
R bugs/FIX-BUG-014.md => bugs/archive/FIX-BUG-014.md
+0, -0
R bugs/FIX-BUG-015.md => bugs/archive/FIX-BUG-015.md
+0, -0
R bugs/FIX-BUG-016.md => bugs/archive/FIX-BUG-016.md
+0, -0
R bugs/FIX-BUG-017.md => bugs/archive/FIX-BUG-017.md
+0, -0
R bugs/OPT-001-action-result-code-quality.md => bugs/archive/OPT-001-action-result-code-quality.md
+0, -0
M docs/api/firefox-local-ws.md
+9, -3
 1@@ -145,9 +145,12 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 2 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
 3 - `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
 4 - `snapshot.browser.clients[].final_messages` 只保留当前活跃 bridge client 最近观测到的最终消息,不写入当前持久化表
 5-- `snapshot.browser.instruction_ingest` 暴露最近一次 live ingest / execute 最小摘要:
 6+- `snapshot.browser.instruction_ingest` 暴露 live ingest / execute 的持久化读面:
 7   - `last_ingest`
 8   - `last_execute`
 9+  - `recent_ingests`
10+  - `recent_executes`
11+- 上述摘要历史来自 conductor 本地有界 journal;进程重启后仍可恢复
12 - `snapshot.browser.clients[].request_hooks` 只回传 endpoint 列表、`endpoint_metadata` 和更新时间
13 
14 ### `action_request`
15@@ -260,13 +263,16 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
16 - 当前只允许 Phase 1 精确 target:
17   - `conductor`
18   - `system`
19-- 当前仍不包含 artifact materialization / upload / inject / send
20+- 当前 server 已把 live `browser.final_message` 执行结果接到 artifact materialization / upload / inject / send
21+- 但插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
22+- artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
23+- 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
24 
25 server 行为:
26 
27 - 把最新 final message 去重后写入当前 client 的最近快照
28 - 以 `platform + assistant_message_id + raw_text` 做 live message replay 抑制
29-- 把最近一次 ingest / execute 摘要暴露到:
30+- 把持久化的 ingest / execute 最近历史暴露到:
31   - `state_snapshot.snapshot.browser.instruction_ingest`
32   - `GET /v1/browser`
33 
M docs/baa-instruction-system-v5/docs/04-execution-loop-state-machine.md
+9, -0
 1@@ -118,6 +118,15 @@ assistant final message ready
 2 - `conversation_id`
 3 - `assistant_message_id`
 4 
 5+当前 conductor 首版已落地:
 6+- final message dedupe 持久化
 7+- instruction dedupe 持久化
 8+- bounded ingest / execute journal 持久化
 9+
10+当前实现边界:
11+- 单节点 sqlite 持久化
12+- 固定上限淘汰,不保留无限历史
13+
14 ## 4.8 终止条件
15 
16 满足任一条件应停止自动循环:
M docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md
+11, -4
 1@@ -142,7 +142,7 @@ conductor -> 插件
 2       "artifact_id": "art_01",
 3       "filename": "baa-result_t9ab_r03_b01_exec_conductor_fail.log",
 4       "mime_type": "text/plain",
 5-      "local_path": "/tmp/baa/art_01.log"
 6+      "download_url": "http://127.0.0.1:4317/v1/browser/delivery/artifacts/plan_01JQ.../art_01"
 7     }
 8   ]
 9 }
10@@ -218,12 +218,19 @@ packages/schemas/src/
11 4. 插件按 plan 上传
12 5. 插件返回 receipt
13 6. conductor 放行注入文本
14+7. auto-send 允许时再执行 send
15 
16 补充:
17 
18-- 当前 repo 已落地到第 2 步:Firefox WS `browser.final_message` 会直接进入 conductor instruction center
19-- 当前还没有 artifact / upload / inject / send 闭环,最近结果只保留 live runtime 摘要
20-7. 插件注入并发送
21+- 当前 repo 已落地到第 7 步的单客户端首版:
22+  - `browser.final_message` -> instruction ingest
23+  - service-side materialize / manifest / delivery plan
24+  - `browser.upload_artifacts` -> `browser.upload_receipt`
25+  - `browser.inject_message` -> `browser.send_message`
26+- 当前首版 delivery 仍保留这些边界:
27+  - artifact download 还没接
28+  - 插件 upload / inject / send 先覆盖 Claude / ChatGPT shell tab
29+  - upload payload 通过本地 `download_url` 读取,不做跨节点分发
30 
31 这样你就实现了:
32 - 插件很薄
M docs/baa-instruction-system-v5/docs/09-artifact-delivery-thin-plugin.md
+8, -3
 1@@ -84,6 +84,7 @@ raw execution result
 2   - `materialize.ts`
 3   - `manifest.ts`
 4   - `delivery-plan.ts`
 5+  - `upload-session.ts`
 6 - 当前 conductor 已能用 synthetic execution result 稳定生成:
 7   - artifact refs
 8   - auditable manifest
 9@@ -95,10 +96,14 @@ raw execution result
10   - `files/write`
11   - `describe`
12   - `status`
13+- 当前已实现的 live delivery 首版:
14+  - conductor 会创建 upload session,并通过 `browser.upload_artifacts` 下发 `download_url`
15+  - 插件会回 `browser.upload_receipt`
16+  - receipt barrier 完成后才会放行 `browser.inject_message`
17+  - `autoSend=true` 时,`browser.send_message` 只会发生在 inject 成功之后
18 - 当前仍未实现:
19-  - 插件实际 upload / download 执行
20-  - upload receipt barrier
21-  - 依赖 receipt 的自动注入 / 自动发送
22+  - artifact download
23+  - Claude / ChatGPT 以外平台的正式 delivery
24 
25 ## 9.7 上传确认 barrier
26 
M docs/firefox/README.md
+5, -2
 1@@ -75,7 +75,7 @@
 2 - `browser.final_message` 现在会直接进入 conductor 侧 `BaaInstructionCenter`;普通消息若不含 ` ```baa ` 会被安全忽略
 3 - 当前服务端会同时保留:
 4   - 活跃 bridge client 的最近最终消息快照
 5-  - 最近一次 `instruction_ingest` / `execute` 最小摘要(见 `GET /v1/browser`)
 6+  - 持久化的 `instruction_ingest` / `execute` 最近历史(见 `GET /v1/browser` 的 `last_*` / `recent_*`)
 7 - 当前仍没有 artifact packaging、upload、inject 或自动 send
 8 
 9 `GET /v1/browser` 会把活跃 WS 连接和持久化记录合并成统一读面,并暴露 `fresh` / `stale` / `lost`。
10@@ -172,7 +172,10 @@
11   - `conductor`
12   - `system`
13 - `conversation_id` 允许为空;当前 replay 去重至少覆盖 `platform + assistant_message_id + raw_text`
14-- 当前仍未接 artifact / upload / inject / send 闭环
15+- 当前 live 路径已经接到 artifact / upload / inject / send 闭环
16+- 但插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
17+- artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
18+- 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
19 
20 ## 浏览器本地代发
21 
A ops/sql/migrations/0003_baa_execution_persistence.sql
+50, -0
 1@@ -0,0 +1,50 @@
 2+-- BAA instruction dedupe and execution journal persistence.
 3+
 4+BEGIN TRANSACTION;
 5+
 6+CREATE TABLE IF NOT EXISTS baa_message_dedupes (
 7+  record_id INTEGER PRIMARY KEY AUTOINCREMENT,
 8+  dedupe_key TEXT NOT NULL UNIQUE,
 9+  assistant_message_id TEXT NOT NULL,
10+  conversation_id TEXT,
11+  platform TEXT NOT NULL,
12+  observed_at INTEGER,
13+  created_at INTEGER NOT NULL
14+);
15+
16+CREATE INDEX IF NOT EXISTS idx_baa_message_dedupes_created
17+ON baa_message_dedupes(created_at, record_id);
18+
19+CREATE TABLE IF NOT EXISTS baa_instruction_dedupes (
20+  record_id INTEGER PRIMARY KEY AUTOINCREMENT,
21+  dedupe_key TEXT NOT NULL UNIQUE,
22+  instruction_id TEXT NOT NULL,
23+  assistant_message_id TEXT NOT NULL,
24+  conversation_id TEXT,
25+  platform TEXT NOT NULL,
26+  target TEXT NOT NULL,
27+  tool TEXT NOT NULL,
28+  created_at INTEGER NOT NULL
29+);
30+
31+CREATE INDEX IF NOT EXISTS idx_baa_instruction_dedupes_created
32+ON baa_instruction_dedupes(created_at, record_id);
33+
34+CREATE TABLE IF NOT EXISTS baa_execution_journal (
35+  journal_id INTEGER PRIMARY KEY AUTOINCREMENT,
36+  summary_kind TEXT NOT NULL CHECK (summary_kind IN ('ingest', 'execute')),
37+  assistant_message_id TEXT NOT NULL,
38+  conversation_id TEXT,
39+  message_dedupe_key TEXT NOT NULL,
40+  platform TEXT NOT NULL,
41+  source TEXT NOT NULL,
42+  status TEXT NOT NULL,
43+  ingested_at INTEGER NOT NULL,
44+  observed_at INTEGER,
45+  summary_json TEXT NOT NULL
46+);
47+
48+CREATE INDEX IF NOT EXISTS idx_baa_execution_journal_kind_ingested
49+ON baa_execution_journal(summary_kind, ingested_at, journal_id);
50+
51+COMMIT;
M ops/sql/schema.sql
+45, -0
 1@@ -272,4 +272,49 @@ CREATE TABLE IF NOT EXISTS browser_endpoint_metadata (
 2 CREATE INDEX IF NOT EXISTS idx_browser_endpoint_metadata_updated
 3 ON browser_endpoint_metadata(platform, account, updated_at);
 4 
 5+CREATE TABLE IF NOT EXISTS baa_message_dedupes (
 6+  record_id INTEGER PRIMARY KEY AUTOINCREMENT,
 7+  dedupe_key TEXT NOT NULL UNIQUE,
 8+  assistant_message_id TEXT NOT NULL,
 9+  conversation_id TEXT,
10+  platform TEXT NOT NULL,
11+  observed_at INTEGER,
12+  created_at INTEGER NOT NULL
13+);
14+
15+CREATE INDEX IF NOT EXISTS idx_baa_message_dedupes_created
16+ON baa_message_dedupes(created_at, record_id);
17+
18+CREATE TABLE IF NOT EXISTS baa_instruction_dedupes (
19+  record_id INTEGER PRIMARY KEY AUTOINCREMENT,
20+  dedupe_key TEXT NOT NULL UNIQUE,
21+  instruction_id TEXT NOT NULL,
22+  assistant_message_id TEXT NOT NULL,
23+  conversation_id TEXT,
24+  platform TEXT NOT NULL,
25+  target TEXT NOT NULL,
26+  tool TEXT NOT NULL,
27+  created_at INTEGER NOT NULL
28+);
29+
30+CREATE INDEX IF NOT EXISTS idx_baa_instruction_dedupes_created
31+ON baa_instruction_dedupes(created_at, record_id);
32+
33+CREATE TABLE IF NOT EXISTS baa_execution_journal (
34+  journal_id INTEGER PRIMARY KEY AUTOINCREMENT,
35+  summary_kind TEXT NOT NULL CHECK (summary_kind IN ('ingest', 'execute')),
36+  assistant_message_id TEXT NOT NULL,
37+  conversation_id TEXT,
38+  message_dedupe_key TEXT NOT NULL,
39+  platform TEXT NOT NULL,
40+  source TEXT NOT NULL,
41+  status TEXT NOT NULL,
42+  ingested_at INTEGER NOT NULL,
43+  observed_at INTEGER,
44+  summary_json TEXT NOT NULL
45+);
46+
47+CREATE INDEX IF NOT EXISTS idx_baa_execution_journal_kind_ingested
48+ON baa_execution_journal(summary_kind, ingested_at, journal_id);
49+
50 COMMIT;
M packages/db/src/index.test.js
+140, -0
  1@@ -432,3 +432,143 @@ test("browser login state status transitions from fresh to stale to lost", async
  2     db.close();
  3   }
  4 });
  5+
  6+test("D1ControlPlaneRepository bounds BAA dedupe tables and journal with stable oldest-first pruning", async () => {
  7+  const db = new SqliteD1Database(":memory:", {
  8+    schemaSql: CONTROL_PLANE_SCHEMA_SQL
  9+  });
 10+  const repository = new D1ControlPlaneRepository(db, {
 11+    baaExecutionJournalLimit: 2,
 12+    baaInstructionDedupeLimit: 2,
 13+    baaMessageDedupeLimit: 2
 14+  });
 15+
 16+  try {
 17+    await repository.putBaaMessageDedupe({
 18+      assistantMessageId: "msg-1",
 19+      conversationId: null,
 20+      createdAt: 100,
 21+      dedupeKey: "message-1",
 22+      observedAt: null,
 23+      platform: "chatgpt"
 24+    });
 25+    await repository.putBaaMessageDedupe({
 26+      assistantMessageId: "msg-2",
 27+      conversationId: null,
 28+      createdAt: 101,
 29+      dedupeKey: "message-2",
 30+      observedAt: null,
 31+      platform: "chatgpt"
 32+    });
 33+    await repository.putBaaMessageDedupe({
 34+      assistantMessageId: "msg-3",
 35+      conversationId: null,
 36+      createdAt: 102,
 37+      dedupeKey: "message-3",
 38+      observedAt: null,
 39+      platform: "chatgpt"
 40+    });
 41+
 42+    assert.equal(await repository.hasBaaMessageDedupe("message-1"), false);
 43+    assert.equal(await repository.hasBaaMessageDedupe("message-2"), true);
 44+    assert.equal(await repository.hasBaaMessageDedupe("message-3"), true);
 45+
 46+    await repository.putBaaInstructionDedupe({
 47+      assistantMessageId: "msg-1",
 48+      conversationId: null,
 49+      createdAt: 100,
 50+      dedupeKey: "inst-1",
 51+      instructionId: "instruction-1",
 52+      platform: "chatgpt",
 53+      target: "conductor",
 54+      tool: "describe"
 55+    });
 56+    await repository.putBaaInstructionDedupe({
 57+      assistantMessageId: "msg-2",
 58+      conversationId: null,
 59+      createdAt: 101,
 60+      dedupeKey: "inst-2",
 61+      instructionId: "instruction-2",
 62+      platform: "chatgpt",
 63+      target: "conductor",
 64+      tool: "status"
 65+    });
 66+    await repository.putBaaInstructionDedupe({
 67+      assistantMessageId: "msg-3",
 68+      conversationId: null,
 69+      createdAt: 102,
 70+      dedupeKey: "inst-3",
 71+      instructionId: "instruction-3",
 72+      platform: "chatgpt",
 73+      target: "conductor",
 74+      tool: "exec"
 75+    });
 76+
 77+    assert.equal(await repository.hasBaaInstructionDedupe("inst-1"), false);
 78+    assert.equal(await repository.hasBaaInstructionDedupe("inst-2"), true);
 79+    assert.equal(await repository.hasBaaInstructionDedupe("inst-3"), true);
 80+
 81+    await repository.appendBaaExecutionJournal({
 82+      assistantMessageId: "msg-1",
 83+      conversationId: null,
 84+      ingestedAt: 200,
 85+      kind: "ingest",
 86+      messageDedupeKey: "message-1",
 87+      observedAt: null,
 88+      platform: "chatgpt",
 89+      source: "browser.final_message",
 90+      status: "executed",
 91+      summaryJson: JSON.stringify({
 92+        assistant_message_id: "msg-1",
 93+        status: "executed"
 94+      })
 95+    });
 96+    await repository.appendBaaExecutionJournal({
 97+      assistantMessageId: "msg-2",
 98+      conversationId: null,
 99+      ingestedAt: 200,
100+      kind: "ingest",
101+      messageDedupeKey: "message-2",
102+      observedAt: null,
103+      platform: "chatgpt",
104+      source: "browser.final_message",
105+      status: "duplicate_message",
106+      summaryJson: JSON.stringify({
107+        assistant_message_id: "msg-2",
108+        status: "duplicate_message"
109+      })
110+    });
111+    await repository.appendBaaExecutionJournal({
112+      assistantMessageId: "msg-3",
113+      conversationId: null,
114+      ingestedAt: 200,
115+      kind: "ingest",
116+      messageDedupeKey: "message-3",
117+      observedAt: null,
118+      platform: "chatgpt",
119+      source: "browser.final_message",
120+      status: "failed",
121+      summaryJson: JSON.stringify({
122+        assistant_message_id: "msg-3",
123+        status: "failed"
124+      })
125+    });
126+
127+    const journal = await repository.listBaaExecutionJournal({
128+      kind: "ingest",
129+      limit: 10
130+    });
131+
132+    assert.equal(journal.length, 2);
133+    assert.deepEqual(
134+      journal.map((entry) => entry.assistantMessageId),
135+      ["msg-3", "msg-2"]
136+    );
137+    assert.deepEqual(
138+      journal.map((entry) => entry.status),
139+      ["failed", "duplicate_message"]
140+    );
141+  } finally {
142+    db.close();
143+  }
144+});
M packages/db/src/index.ts
+319, -4
  1@@ -14,7 +14,10 @@ export const D1_TABLES = [
  2   "system_state",
  3   "task_artifacts",
  4   "browser_login_states",
  5-  "browser_endpoint_metadata"
  6+  "browser_endpoint_metadata",
  7+  "baa_message_dedupes",
  8+  "baa_instruction_dedupes",
  9+  "baa_execution_journal"
 10 ] as const;
 11 
 12 export type D1TableName = (typeof D1_TABLES)[number];
 13@@ -25,6 +28,9 @@ export const DEFAULT_AUTOMATION_MODE = "running";
 14 export const DEFAULT_LEASE_TTL_SEC = 30;
 15 export const DEFAULT_LEASE_RENEW_INTERVAL_SEC = 5;
 16 export const DEFAULT_LEASE_RENEW_FAILURE_THRESHOLD = 2;
 17+export const DEFAULT_BAA_MESSAGE_DEDUPE_LIMIT = 2_048;
 18+export const DEFAULT_BAA_INSTRUCTION_DEDUPE_LIMIT = 8_192;
 19+export const DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT = 50;
 20 
 21 export const AUTOMATION_MODE_VALUES = ["running", "draining", "paused"] as const;
 22 export const TASK_STATUS_VALUES = [
 23@@ -39,12 +45,14 @@ export const TASK_STATUS_VALUES = [
 24 export const STEP_STATUS_VALUES = ["pending", "running", "done", "failed", "timeout"] as const;
 25 export const STEP_KIND_VALUES = ["planner", "codex", "shell", "git", "review", "finalize"] as const;
 26 export const BROWSER_LOGIN_STATE_STATUS_VALUES = ["fresh", "stale", "lost"] as const;
 27+export const BAA_EXECUTION_JOURNAL_KIND_VALUES = ["ingest", "execute"] as const;
 28 
 29 export type AutomationMode = (typeof AUTOMATION_MODE_VALUES)[number];
 30 export type TaskStatus = (typeof TASK_STATUS_VALUES)[number];
 31 export type StepStatus = (typeof STEP_STATUS_VALUES)[number];
 32 export type StepKind = (typeof STEP_KIND_VALUES)[number];
 33 export type BrowserLoginStateStatus = (typeof BROWSER_LOGIN_STATE_STATUS_VALUES)[number];
 34+export type BaaExecutionJournalKind = (typeof BAA_EXECUTION_JOURNAL_KIND_VALUES)[number];
 35 
 36 export type JsonPrimitive = boolean | number | null | string;
 37 export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
 38@@ -322,6 +330,43 @@ export interface BrowserEndpointMetadataRecord extends BrowserSessionKey {
 39   lastVerifiedAt: number | null;
 40 }
 41 
 42+export interface NewBaaMessageDedupeRecord {
 43+  assistantMessageId: string;
 44+  conversationId: string | null;
 45+  createdAt: number;
 46+  dedupeKey: string;
 47+  observedAt: number | null;
 48+  platform: string;
 49+}
 50+
 51+export interface NewBaaInstructionDedupeRecord {
 52+  assistantMessageId: string;
 53+  conversationId: string | null;
 54+  createdAt: number;
 55+  dedupeKey: string;
 56+  instructionId: string;
 57+  platform: string;
 58+  target: string;
 59+  tool: string;
 60+}
 61+
 62+export interface NewBaaExecutionJournalRecord {
 63+  assistantMessageId: string;
 64+  conversationId: string | null;
 65+  ingestedAt: number;
 66+  kind: BaaExecutionJournalKind;
 67+  messageDedupeKey: string;
 68+  observedAt: number | null;
 69+  platform: string;
 70+  source: string;
 71+  status: string;
 72+  summaryJson: string;
 73+}
 74+
 75+export interface BaaExecutionJournalRecord extends NewBaaExecutionJournalRecord {
 76+  journalId: number;
 77+}
 78+
 79 export interface ListControllersOptions {
 80   limit?: number;
 81 }
 82@@ -357,7 +402,19 @@ export interface ListBrowserEndpointMetadataOptions {
 83   platform?: string;
 84 }
 85 
 86+export interface ListBaaExecutionJournalOptions {
 87+  kind?: BaaExecutionJournalKind;
 88+  limit?: number;
 89+}
 90+
 91+export interface D1ControlPlaneRepositoryOptions {
 92+  baaExecutionJournalLimit?: number;
 93+  baaInstructionDedupeLimit?: number;
 94+  baaMessageDedupeLimit?: number;
 95+}
 96+
 97 export interface ControlPlaneRepository {
 98+  appendBaaExecutionJournal(record: NewBaaExecutionJournalRecord): Promise<number | null>;
 99   appendTaskLog(record: NewTaskLogRecord): Promise<number | null>;
100   countActiveRuns(): Promise<number>;
101   countQueuedTasks(): Promise<number>;
102@@ -372,12 +429,15 @@ export interface ControlPlaneRepository {
103   getRun(runId: string): Promise<TaskRunRecord | null>;
104   getSystemState(stateKey: string): Promise<SystemStateRecord | null>;
105   getTask(taskId: string): Promise<TaskRecord | null>;
106+  hasBaaInstructionDedupe(dedupeKey: string): Promise<boolean>;
107+  hasBaaMessageDedupe(dedupeKey: string): Promise<boolean>;
108   insertTask(record: TaskRecord): Promise<void>;
109   insertTaskArtifact(record: TaskArtifactRecord): Promise<void>;
110   insertTaskCheckpoint(record: TaskCheckpointRecord): Promise<void>;
111   insertTaskRun(record: TaskRunRecord): Promise<void>;
112   insertTaskStep(record: TaskStepRecord): Promise<void>;
113   insertTaskSteps(records: TaskStepRecord[]): Promise<void>;
114+  listBaaExecutionJournal(options?: ListBaaExecutionJournalOptions): Promise<BaaExecutionJournalRecord[]>;
115   listBrowserEndpointMetadata(
116     options?: ListBrowserEndpointMetadataOptions
117   ): Promise<BrowserEndpointMetadataRecord[]>;
118@@ -390,6 +450,8 @@ export interface ControlPlaneRepository {
119   markBrowserLoginStatesLost(lastSeenBeforeOrAt: number): Promise<number>;
120   markBrowserLoginStatesStale(lastSeenBeforeOrAt: number): Promise<number>;
121   putLeaderLease(record: LeaderLeaseRecord): Promise<void>;
122+  putBaaInstructionDedupe(record: NewBaaInstructionDedupeRecord): Promise<void>;
123+  putBaaMessageDedupe(record: NewBaaMessageDedupeRecord): Promise<void>;
124   putSystemState(record: SystemStateRecord): Promise<void>;
125   setAutomationMode(mode: AutomationMode, updatedAt?: number): Promise<void>;
126   upsertBrowserEndpointMetadata(record: BrowserEndpointMetadataRecord): Promise<void>;
127@@ -403,6 +465,7 @@ const TASK_STATUS_SET = new Set<string>(TASK_STATUS_VALUES);
128 const STEP_STATUS_SET = new Set<string>(STEP_STATUS_VALUES);
129 const STEP_KIND_SET = new Set<string>(STEP_KIND_VALUES);
130 const BROWSER_LOGIN_STATE_STATUS_SET = new Set<string>(BROWSER_LOGIN_STATE_STATUS_VALUES);
131+const BAA_EXECUTION_JOURNAL_KIND_SET = new Set<string>(BAA_EXECUTION_JOURNAL_KIND_VALUES);
132 
133 export function nowUnixSeconds(date: Date = new Date()): number {
134   return Math.floor(date.getTime() / 1000);
135@@ -444,6 +507,10 @@ export function isBrowserLoginStateStatus(value: unknown): value is BrowserLogin
136   return typeof value === "string" && BROWSER_LOGIN_STATE_STATUS_SET.has(value);
137 }
138 
139+export function isBaaExecutionJournalKind(value: unknown): value is BaaExecutionJournalKind {
140+  return typeof value === "string" && BAA_EXECUTION_JOURNAL_KIND_SET.has(value);
141+}
142+
143 export function buildAutomationStateValue(mode: AutomationMode): string {
144   return JSON.stringify({ mode });
145 }
146@@ -630,6 +697,16 @@ function readBrowserLoginStateStatus(row: DatabaseRow, column: string): BrowserL
147   return value;
148 }
149 
150+function readBaaExecutionJournalKind(row: DatabaseRow, column: string): BaaExecutionJournalKind {
151+  const value = readRequiredString(row, column);
152+
153+  if (!isBaaExecutionJournalKind(value)) {
154+    throw new TypeError(`Unexpected BAA execution journal kind "${value}".`);
155+  }
156+
157+  return value;
158+}
159+
160 function normalizeStringArray(values: readonly string[]): string[] {
161   const uniqueValues = new Set<string>();
162 
163@@ -668,6 +745,15 @@ function parseStringArrayJson(jsonText: string | null | undefined, column: strin
164   return normalizeStringArray(values);
165 }
166 
167+function normalizePositiveInteger(value: number | null | undefined, fallback: number): number {
168+  if (typeof value !== "number" || !Number.isFinite(value)) {
169+    return fallback;
170+  }
171+
172+  const normalized = Math.trunc(value);
173+  return normalized > 0 ? normalized : fallback;
174+}
175+
176 export function mapLeaderLeaseRow(row: DatabaseRow): LeaderLeaseRecord {
177   return {
178     leaseName: readRequiredString(row, "lease_name"),
179@@ -890,6 +976,22 @@ export function mapBrowserEndpointMetadataRow(row: DatabaseRow): BrowserEndpoint
180   };
181 }
182 
183+export function mapBaaExecutionJournalRow(row: DatabaseRow): BaaExecutionJournalRecord {
184+  return {
185+    journalId: readRequiredNumber(row, "journal_id"),
186+    kind: readBaaExecutionJournalKind(row, "summary_kind"),
187+    assistantMessageId: readRequiredString(row, "assistant_message_id"),
188+    conversationId: readOptionalString(row, "conversation_id"),
189+    ingestedAt: readRequiredNumber(row, "ingested_at"),
190+    messageDedupeKey: readRequiredString(row, "message_dedupe_key"),
191+    observedAt: readOptionalNumber(row, "observed_at"),
192+    platform: readRequiredString(row, "platform"),
193+    source: readRequiredString(row, "source"),
194+    status: readRequiredString(row, "status"),
195+    summaryJson: readRequiredString(row, "summary_json")
196+  };
197+}
198+
199 export const SELECT_CURRENT_LEASE_SQL = `
200   SELECT
201     lease_name,
202@@ -1577,6 +1679,110 @@ export const SELECT_BROWSER_ENDPOINT_METADATA_PREFIX_SQL = `
203   FROM browser_endpoint_metadata
204 `;
205 
206+export const INSERT_BAA_MESSAGE_DEDUPE_SQL = `
207+  INSERT INTO baa_message_dedupes (
208+    dedupe_key,
209+    assistant_message_id,
210+    conversation_id,
211+    platform,
212+    observed_at,
213+    created_at
214+  )
215+  VALUES (?, ?, ?, ?, ?, ?)
216+  ON CONFLICT(dedupe_key) DO NOTHING
217+`;
218+
219+export const SELECT_BAA_MESSAGE_DEDUPE_EXISTS_SQL = `
220+  SELECT 1 AS value
221+  FROM baa_message_dedupes
222+  WHERE dedupe_key = ?
223+  LIMIT 1
224+`;
225+
226+export const PRUNE_BAA_MESSAGE_DEDUPE_SQL = `
227+  DELETE FROM baa_message_dedupes
228+  WHERE record_id IN (
229+    SELECT record_id
230+    FROM baa_message_dedupes
231+    ORDER BY created_at DESC, record_id DESC
232+    LIMIT -1 OFFSET ?
233+  )
234+`;
235+
236+export const INSERT_BAA_INSTRUCTION_DEDUPE_SQL = `
237+  INSERT INTO baa_instruction_dedupes (
238+    dedupe_key,
239+    instruction_id,
240+    assistant_message_id,
241+    conversation_id,
242+    platform,
243+    target,
244+    tool,
245+    created_at
246+  )
247+  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
248+  ON CONFLICT(dedupe_key) DO NOTHING
249+`;
250+
251+export const SELECT_BAA_INSTRUCTION_DEDUPE_EXISTS_SQL = `
252+  SELECT 1 AS value
253+  FROM baa_instruction_dedupes
254+  WHERE dedupe_key = ?
255+  LIMIT 1
256+`;
257+
258+export const PRUNE_BAA_INSTRUCTION_DEDUPE_SQL = `
259+  DELETE FROM baa_instruction_dedupes
260+  WHERE record_id IN (
261+    SELECT record_id
262+    FROM baa_instruction_dedupes
263+    ORDER BY created_at DESC, record_id DESC
264+    LIMIT -1 OFFSET ?
265+  )
266+`;
267+
268+export const INSERT_BAA_EXECUTION_JOURNAL_SQL = `
269+  INSERT INTO baa_execution_journal (
270+    summary_kind,
271+    assistant_message_id,
272+    conversation_id,
273+    message_dedupe_key,
274+    platform,
275+    source,
276+    status,
277+    ingested_at,
278+    observed_at,
279+    summary_json
280+  )
281+  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
282+`;
283+
284+export const SELECT_BAA_EXECUTION_JOURNAL_PREFIX_SQL = `
285+  SELECT
286+    journal_id,
287+    summary_kind,
288+    assistant_message_id,
289+    conversation_id,
290+    message_dedupe_key,
291+    platform,
292+    source,
293+    status,
294+    ingested_at,
295+    observed_at,
296+    summary_json
297+  FROM baa_execution_journal
298+`;
299+
300+export const PRUNE_BAA_EXECUTION_JOURNAL_SQL = `
301+  DELETE FROM baa_execution_journal
302+  WHERE journal_id IN (
303+    SELECT journal_id
304+    FROM baa_execution_journal
305+    ORDER BY ingested_at DESC, journal_id DESC
306+    LIMIT -1 OFFSET ?
307+  )
308+`;
309+
310 function leaderLeaseParams(record: LeaderLeaseRecord): D1Bindable[] {
311   return [
312     record.leaseName,
313@@ -1781,6 +1987,45 @@ function browserEndpointMetadataParams(record: BrowserEndpointMetadataRecord): D
314   ];
315 }
316 
317+function baaMessageDedupeParams(record: NewBaaMessageDedupeRecord): D1Bindable[] {
318+  return [
319+    record.dedupeKey,
320+    record.assistantMessageId,
321+    record.conversationId,
322+    record.platform,
323+    record.observedAt,
324+    record.createdAt
325+  ];
326+}
327+
328+function baaInstructionDedupeParams(record: NewBaaInstructionDedupeRecord): D1Bindable[] {
329+  return [
330+    record.dedupeKey,
331+    record.instructionId,
332+    record.assistantMessageId,
333+    record.conversationId,
334+    record.platform,
335+    record.target,
336+    record.tool,
337+    record.createdAt
338+  ];
339+}
340+
341+function baaExecutionJournalParams(record: NewBaaExecutionJournalRecord): D1Bindable[] {
342+  return [
343+    record.kind,
344+    record.assistantMessageId,
345+    record.conversationId,
346+    record.messageDedupeKey,
347+    record.platform,
348+    record.source,
349+    record.status,
350+    record.ingestedAt,
351+    record.observedAt,
352+    record.summaryJson
353+  ];
354+}
355+
356 function buildBrowserLoginStatesListQuery(
357   options: ListBrowserLoginStatesOptions
358 ): { query: string; params: D1Bindable[] } {
359@@ -1856,6 +2101,26 @@ function buildBrowserEndpointMetadataListQuery(
360   return { query, params };
361 }
362 
363+function buildBaaExecutionJournalListQuery(
364+  options: ListBaaExecutionJournalOptions
365+): { query: string; params: D1Bindable[] } {
366+  const clauses: string[] = [];
367+  const params: D1Bindable[] = [];
368+
369+  if (options.kind != null) {
370+    clauses.push("summary_kind = ?");
371+    params.push(options.kind);
372+  }
373+
374+  const whereClause = clauses.length === 0 ? "" : `\nWHERE ${clauses.join("\n  AND ")}`;
375+  const query = `${SELECT_BAA_EXECUTION_JOURNAL_PREFIX_SQL}${whereClause}
376+  ORDER BY ingested_at DESC, journal_id DESC
377+  LIMIT ?`;
378+
379+  params.push(options.limit ?? DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT);
380+  return { query, params };
381+}
382+
383 function sqliteQueryMeta(extra: D1ResultMeta = {}): D1ResultMeta {
384   return {
385     changes: 0,
386@@ -1996,10 +2261,25 @@ export class SqliteD1Database implements D1DatabaseLike {
387 }
388 
389 export class D1ControlPlaneRepository implements ControlPlaneRepository {
390+  private readonly baaExecutionJournalLimit: number;
391+  private readonly baaInstructionDedupeLimit: number;
392+  private readonly baaMessageDedupeLimit: number;
393   private readonly db: D1DatabaseLike;
394 
395-  constructor(db: D1DatabaseLike) {
396+  constructor(db: D1DatabaseLike, options: D1ControlPlaneRepositoryOptions = {}) {
397     this.db = db;
398+    this.baaExecutionJournalLimit = normalizePositiveInteger(
399+      options.baaExecutionJournalLimit,
400+      DEFAULT_BAA_EXECUTION_JOURNAL_LIMIT
401+    );
402+    this.baaInstructionDedupeLimit = normalizePositiveInteger(
403+      options.baaInstructionDedupeLimit,
404+      DEFAULT_BAA_INSTRUCTION_DEDUPE_LIMIT
405+    );
406+    this.baaMessageDedupeLimit = normalizePositiveInteger(
407+      options.baaMessageDedupeLimit,
408+      DEFAULT_BAA_MESSAGE_DEDUPE_LIMIT
409+    );
410   }
411 
412   async ensureAutomationState(mode: AutomationMode = DEFAULT_AUTOMATION_MODE): Promise<void> {
413@@ -2081,6 +2361,14 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
414     return row == null ? null : mapTaskRow(row);
415   }
416 
417+  async hasBaaInstructionDedupe(dedupeKey: string): Promise<boolean> {
418+    return (await this.fetchFirst(SELECT_BAA_INSTRUCTION_DEDUPE_EXISTS_SQL, [dedupeKey])) != null;
419+  }
420+
421+  async hasBaaMessageDedupe(dedupeKey: string): Promise<boolean> {
422+    return (await this.fetchFirst(SELECT_BAA_MESSAGE_DEDUPE_EXISTS_SQL, [dedupeKey])) != null;
423+  }
424+
425   async upsertBrowserEndpointMetadata(record: BrowserEndpointMetadataRecord): Promise<void> {
426     await this.run(UPSERT_BROWSER_ENDPOINT_METADATA_SQL, browserEndpointMetadataParams(record));
427   }
428@@ -2089,6 +2377,16 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
429     await this.run(UPSERT_BROWSER_LOGIN_STATE_SQL, browserLoginStateParams(record));
430   }
431 
432+  async putBaaInstructionDedupe(record: NewBaaInstructionDedupeRecord): Promise<void> {
433+    await this.run(INSERT_BAA_INSTRUCTION_DEDUPE_SQL, baaInstructionDedupeParams(record));
434+    await this.run(PRUNE_BAA_INSTRUCTION_DEDUPE_SQL, [this.baaInstructionDedupeLimit]);
435+  }
436+
437+  async putBaaMessageDedupe(record: NewBaaMessageDedupeRecord): Promise<void> {
438+    await this.run(INSERT_BAA_MESSAGE_DEDUPE_SQL, baaMessageDedupeParams(record));
439+    await this.run(PRUNE_BAA_MESSAGE_DEDUPE_SQL, [this.baaMessageDedupeLimit]);
440+  }
441+
442   async insertTask(record: TaskRecord): Promise<void> {
443     await this.run(INSERT_TASK_SQL, taskParams(record));
444   }
445@@ -2130,6 +2428,14 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
446     return rows.map(mapBrowserEndpointMetadataRow);
447   }
448 
449+  async listBaaExecutionJournal(
450+    options: ListBaaExecutionJournalOptions = {}
451+  ): Promise<BaaExecutionJournalRecord[]> {
452+    const { query, params } = buildBaaExecutionJournalListQuery(options);
453+    const rows = await this.fetchAll(query, params);
454+    return rows.map(mapBaaExecutionJournalRow);
455+  }
456+
457   async listBrowserLoginStates(
458     options: ListBrowserLoginStatesOptions = {}
459   ): Promise<BrowserLoginStateRecord[]> {
460@@ -2210,6 +2516,12 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
461     return result.meta.last_row_id ?? null;
462   }
463 
464+  async appendBaaExecutionJournal(record: NewBaaExecutionJournalRecord): Promise<number | null> {
465+    const result = await this.run(INSERT_BAA_EXECUTION_JOURNAL_SQL, baaExecutionJournalParams(record));
466+    await this.run(PRUNE_BAA_EXECUTION_JOURNAL_SQL, [this.baaExecutionJournalLimit]);
467+    return result.meta.last_row_id ?? null;
468+  }
469+
470   private bind(query: string, params: readonly (D1Bindable | undefined)[]): D1PreparedStatementLike {
471     return this.db.prepare(query).bind(...params.map(toD1Bindable));
472   }
473@@ -2245,6 +2557,9 @@ export class D1ControlPlaneRepository implements ControlPlaneRepository {
474   }
475 }
476 
477-export function createD1ControlPlaneRepository(db: D1DatabaseLike): ControlPlaneRepository {
478-  return new D1ControlPlaneRepository(db);
479+export function createD1ControlPlaneRepository(
480+  db: D1DatabaseLike,
481+  options: D1ControlPlaneRepositoryOptions = {}
482+): ControlPlaneRepository {
483+  return new D1ControlPlaneRepository(db, options);
484 }
M plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md
+7, -7
 1@@ -20,7 +20,7 @@
 2 - `instructions/` Phase 1 最小执行闭环
 3 - `browser.final_message` raw relay
 4 
 5-但执行结果当前仍停留在“直接返回原始结果摘要”的阶段,缺少 v5 明确要求的:
 6+但在立项前,执行结果仍停留在“直接返回原始结果摘要”的阶段,缺少 v5 明确要求的:
 7 
 8 - artifact materialization
 9 - manifest / index text
10@@ -63,8 +63,8 @@
11   - `exec` 产出 artifact + manifest + delivery plan
12   - `files/read` 小结果 / 大结果有不同 delivery 策略
13   - 多结果排序稳定,缺少可选字段不会让整批 materialize 崩掉
14-- 当前仍明确未落地:
15-  - Firefox 插件 upload / download 执行
16+- 后续 `T-S034` 已补齐:
17+  - Firefox 插件最小 upload 执行
18   - upload receipt barrier
19   - 浏览器侧 inject / send 主链
20 
21@@ -90,7 +90,7 @@
22 
23 ## 当前预期残余边界
24 
25-- upload / download / receipt barrier 仍留到后续任务
26-- 首版 delivery plan 可以先面向单客户端、单轮交付
27-- 真正的浏览器注入链仍由后续插件任务接上
28-- 当前 live instruction ingest 路径还没有把执行结果真正接到 artifact / upload / inject / send
29+- 首版 delivery plan 与交付当前仍面向单节点、本地 `download_url` 和单客户端单轮场景
30+- artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
31+- execution journal 只保留最近窗口,不扩成无限历史审计
32+- 当前 live instruction ingest 路径已经把执行结果接到 artifact / upload / inject / send,但仍只覆盖 Phase 1 精确 target,不扩到跨节点或完整 task/run 编排
M plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md
+3, -3
 1@@ -127,6 +127,6 @@
 2 
 3 - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面后续调整 payload 形态,需要同步修改提取器
 4 - Gemini 当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析来抽取最终文本;稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
 5-- conductor 侧现已把 `browser.final_message` 接到 live instruction ingest,但 live message dedupe 和 instruction dedupe 都还是进程内内存态,重启后不会保留
 6-- 当前摘要只保留最近一次 live ingest / execute,不落当前持久化表
 7-- 当前 live 路径仍只允许 Phase 1 精确 target,且还没有接 artifact / upload / inject / send
 8+- conductor 侧现已把 `browser.final_message` 接到 live instruction ingest,且 live message dedupe / instruction dedupe 已落到单节点本地持久化;但这层不做跨节点共享
 9+- 当前 ingest / execute 摘要已进入 bounded journal;读面保留最近窗口,不扩成无限历史
10+- 当前 live 路径已接到 artifact / upload / inject / send,但仍只允许 Phase 1 精确 target,不扩到跨节点或完整 task/run 编排
A plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md
+100, -0
  1@@ -0,0 +1,100 @@
  2+# BAA Delivery Bridge 与 Upload Receipt Barrier 需求
  3+
  4+## 状态
  5+
  6+- `已落地(对应 T-S034)`
  7+- 优先级:`high`
  8+- 记录时间:`2026-03-27`
  9+
 10+## 关联文档
 11+
 12+- [BAA_ARTIFACT_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
 13+- [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
 14+- [06-integration-with-current-baa-conductor.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md)
 15+- [09-artifact-delivery-thin-plugin.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/09-artifact-delivery-thin-plugin.md)
 16+
 17+## 背景
 18+
 19+当前 repo 已经完成:
 20+
 21+- service-side artifact center core
 22+- manifest / index text / delivery plan 生成
 23+
 24+但在立项前,live 路径仍没有真正打通:
 25+
 26+- `browser.upload_artifacts`
 27+- `browser.upload_receipt`
 28+- `browser.inject_message`
 29+- `browser.send_message`
 30+
 31+这意味着 artifact core 目前仍停留在 service-side 结果打包阶段,没有形成真正的交付闭环。
 32+
 33+## 核心结论
 34+
 35+- conductor 负责生成 upload-aware delivery plan 和 receipt barrier
 36+- 插件只负责:
 37+  - 上传 artifact
 38+  - 回传 receipt
 39+  - 注入已准备文本
 40+  - 执行发送
 41+- 没有 upload receipt 前,不得放行索引文本注入或自动发送
 42+
 43+## 首版范围
 44+
 45+- conductor upload session / receipt barrier
 46+- WS 消息合同:
 47+  - `browser.upload_artifacts`
 48+  - `browser.upload_receipt`
 49+  - `browser.inject_message`
 50+  - `browser.send_message`
 51+- Firefox 插件最小 upload / inject / send 执行
 52+- 失败重试 / 降级规则
 53+- 自动化 smoke 与文档回写
 54+
 55+## 当前明确不要求
 56+
 57+- 不要求本需求里实现 artifact download 闭环
 58+- 不要求本需求里扩到 ChatGPT / Gemini 正式 `browser.*` target 语义
 59+- 不要求本需求里引入多节点任务池或共识层
 60+
 61+## 验收条件
 62+
 63+- conductor 能发出 upload plan,并等待 receipt
 64+- receipt 未完成前,不会提前 inject / send
 65+- receipt 成功后,插件能注入准备好的 index text,并在允许时执行 send
 66+- 上传失败时有明确重试或降级,不会静默成功
 67+- 文档已同步到 `plans/`、`tasks/` 和必要的 `docs/`
 68+
 69+## 当前预期残余边界
 70+
 71+- 插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
 72+- artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
 73+- 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
 74+- 当前交付仍建立在单节点本地 artifact store 和本地 `download_url` 之上,不做跨节点分发
 75+- execution journal 只保留最近窗口,不扩成无限历史审计
 76+- live 执行路径当前仍只覆盖 Phase 1 精确 target `conductor` / `system`,不扩到跨节点或完整 task/run 编排
 77+
 78+## 完成记录(2026-03-27)
 79+
 80+- conductor 已新增 `apps/conductor-daemon/src/artifacts/upload-session.ts`,负责:
 81+  - materialize / manifest / delivery plan 落地
 82+  - `browser.upload_artifacts` 下发
 83+  - `browser.upload_receipt` barrier 聚合
 84+  - inject/send 顺序控制
 85+- Firefox WS bridge 已补齐:
 86+  - `browser.upload_artifacts`
 87+  - `browser.upload_receipt`
 88+  - `browser.inject_message`
 89+  - `browser.send_message`
 90+- Firefox 插件已补最小 delivery bridge:
 91+  - 通过本地 `download_url` 拉取 artifact payload
 92+  - artifact payload 当前以 base64 JSON 形式下发,优先服务 text/json 类产物
 93+  - 上传成功后回传 receipt
 94+  - 对 Claude / ChatGPT shell tab 执行 inject / send
 95+  - upload / inject / send 仍基于首版 DOM heuristic,不扩到更通用的页面自动化框架
 96+- 已覆盖失败路径:
 97+  - upload receipt 失败会 fail-closed
 98+  - 未拿到完整 receipt 前不会 inject / send
 99+- 已补自动化验证:
100+  - `apps/conductor-daemon/src/index.test.js`
101+  - `tests/browser/browser-control-e2e-smoke.test.mjs`
A plans/BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md
+82, -0
 1@@ -0,0 +1,82 @@
 2+# BAA 执行持久化与去重状态需求
 3+
 4+## 状态
 5+
 6+- `已落地(T-S033 已实现)`
 7+- 优先级:`high`
 8+- 记录时间:`2026-03-27`
 9+
10+## 关联文档
11+
12+- [BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
13+- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
14+- [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
15+- [04-execution-loop-state-machine.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/04-execution-loop-state-machine.md)
16+
17+## 背景
18+
19+当前 repo 已经完成:
20+
21+- `browser.final_message` live ingest
22+- `BaaInstructionCenter` Phase 1 最小闭环
23+- `/v1/browser` 最近一次 `instruction_ingest` 摘要
24+
25+但在立项前还保留 2 个直接影响稳定性的缺口:
26+
27+- live message dedupe 和 instruction dedupe 仍是进程内内存态
28+- 最近摘要只保留最近一次 live ingest / execute,不落库
29+
30+这意味着重启后:
31+
32+- 同一条 final message 可能再次执行
33+- 最近执行历史和诊断摘要会丢失
34+
35+## 核心结论
36+
37+- 首版先补单节点持久化,不讨论多节点一致性
38+- dedupe 状态和 execution journal 都应持久化到 conductor 可恢复的存储
39+- `/v1/browser` 读面不应再只保留“最后一次”,至少要能读取最近若干条审计摘要
40+- 首版不要求把 execution journal 扩成完整任务系统或共识层
41+
42+## 首版范围
43+
44+- live message dedupe 持久化
45+- instruction dedupe 持久化
46+- execution / ingest journal 持久化
47+- `/v1/browser` 最近摘要历史读面
48+- 重启恢复与回归测试
49+
50+## 当前明确不要求
51+
52+- 不要求本需求里做多节点 dedupe 共享
53+- 不要求本需求里做 artifact upload / inject / send
54+- 不要求本需求里把 journal 扩成 task/run 模型
55+
56+## 验收条件
57+
58+- conductor 重启后,同一条已执行过的 final message 不会再次执行
59+- 最近摘要不再只依赖进程内内存态;重启后仍可读
60+- `/v1/browser` 或等价读面能稳定读取最近若干条 ingest / execute 审计摘要
61+- 文档已同步到 `plans/`、`tasks/` 和必要的 `docs/`
62+
63+## 当前预期残余边界
64+
65+- 首版仍可以只做单节点持久化,不要求跨节点共享
66+- journal 首版可以先保留有限条数,不要求无限历史
67+
68+## 已落地结果
69+
70+- `browser.final_message` dedupe 已写入 control-plane sqlite,重启后仍可识别同一条 final message
71+- instruction-level dedupe 已写入 control-plane sqlite,重启后不会重复执行同一 `dedupe_key`
72+- ingest / execute 摘要已写入有界 journal,启动时会恢复到 live ingest snapshot
73+- `GET /v1/browser` 与 Firefox WS `state_snapshot.snapshot.browser.instruction_ingest` 现在同时暴露:
74+  - `last_ingest`
75+  - `last_execute`
76+  - `recent_ingests`
77+  - `recent_executes`
78+
79+## 首版实现约束
80+
81+- 持久化范围仍是单节点;状态落在 conductor 本地 control-plane sqlite
82+- dedupe 表和 journal 都做了固定上限淘汰,按最老记录稳定清理
83+- 首版仍未扩展到 artifact task/run 系统,也不做跨节点共享 dedupe
M plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md
+4, -4
 1@@ -35,7 +35,7 @@
 2   - `conductor`
 3   - `system`
 4 - 首版不要求自动注入 / 自动发送 / delivery plan
 5-- 首版允许只做内存态执行审计和最近结果快照,不要求同时落库
 6+- 立项时允许只做内存态执行审计和最近结果快照,不要求同时落库
 7 
 8 ## 首版范围
 9 
10@@ -71,11 +71,11 @@
11 
12 ## 当前预期残余边界
13 
14-- live message dedupe 和 instruction dedupe 当前都还是进程内内存态,重启后不会保留
15+- live message dedupe 和 instruction dedupe 已落到单节点本地持久化;重启后可恢复,但不做跨节点共享
16 - 当前仍只做本机精确 target:
17   - `conductor`
18   - `system`
19-- 当前最近摘要只保留在 live runtime:
20+- 当前最近摘要已写入 bounded journal,并同步暴露到:
21   - Firefox WS `state_snapshot.snapshot.browser.instruction_ingest`
22   - `GET /v1/browser` → `data.instruction_ingest`
23-- 当前 live ingest 路径还没有把执行结果真正接到 artifact / upload / inject / send
24+- 当前 live ingest 路径已把执行结果接到 artifact / upload / inject / send,但仍不扩到跨节点或完整 task/run 编排
M plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md
+3, -3
 1@@ -192,7 +192,7 @@ apps/conductor-daemon/src/instructions/
 2 
 3 ## 当前残余边界
 4 
 5-- dedupe 目前仍是进程内内存态,进程重启后不会保留
 6+- dedupe 当前已做成单节点本地持久化,进程重启后可恢复,但不做跨节点共享
 7 - 当前只做本机精确 target,跨节点分发和多轮闭环还没接
 8-- 当前 Firefox bridge live `browser.final_message` 已接入 instruction center,但最近摘要只保留在 live runtime,不落库
 9-- 当前 live 路径还没有把执行结果真正接到 artifact / upload / inject / send
10+- 当前 Firefox bridge live `browser.final_message` 已接入 instruction center,最近摘要也已落到 bounded journal;但 journal 只保留最近窗口
11+- 当前 live 路径已把执行结果接到 artifact / upload / inject / send,但还没有扩到跨节点或完整 task/run 编排
M plans/STATUS_SUMMARY.md
+14, -7
 1@@ -8,14 +8,14 @@
 2 
 3 - 浏览器控制主链路收口基线:`main@07895cd`
 4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
 5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032` 落地
 6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 落地
 7 - 任务文档已统一收口到 `tasks/`
 8 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
 9-- `T-S001` 到 `T-S032` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
10+- `T-S001` 到 `T-S034` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
11 
12 ## 当前状态分类
13 
14-- `已完成`:`T-S001` 到 `T-S032`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
15+- `已完成`:`T-S001` 到 `T-S034`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
16 - `当前 TODO`:
17   - `T-S026` 真实 Firefox 手工 smoke 与验收记录
18 - `待处理缺陷`:当前无 open bug backlog(见 `bugs/README.md`)
19@@ -23,6 +23,8 @@
20 
21 当前新的主需求文档:
22 
23+- [`./BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md`](./BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md)
24+- [`./BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`](./BAA_DELIVERY_BRIDGE_REQUIREMENTS.md)
25 - [`./BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`](./BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
26 - [`./BAA_ARTIFACT_CENTER_REQUIREMENTS.md`](./BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
27 - [`./BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`](./BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
28@@ -81,6 +83,8 @@
29 11. `2026-03-27` 跟进任务:`T-S028` 已完成,`platform=chatgpt` 的 `/v1/browser/request` 现已正式支持 path-based buffered / SSE / cancel,并已补到 automated smoke 与文档
30 12. `2026-03-27` 跟进任务:`T-S031` 已完成,`browser.final_message` 已接入 live instruction ingest,并把最近一次 ingest / execute 摘要暴露到 Firefox WS 与 `/v1/browser`
31 13. `2026-03-27` 跟进任务:`T-S032` 已完成,`conductor-daemon` 已补 service-side artifact center core:artifact materialize、manifest / index text 和 delivery plan
32+14. `2026-03-27` 跟进任务:`T-S033` 已完成,live message dedupe、instruction dedupe 和 bounded execution journal 已落到本地 control-plane sqlite,并在重启后恢复
33+15. `2026-03-27` 跟进任务:`T-S034` 已完成,live 执行结果已接到 artifact upload、receipt barrier、inject / send 主链,并补了 fail-closed 自动化 smoke
34 
35 当前策略:
36 
37@@ -170,13 +174,16 @@
38 - 当前 open bug backlog 已清空
39 - 当前主线下一波任务是:
40   - `T-S026`:真实 Firefox 手工 smoke 与验收记录
41-- `T-S029`、`T-S030`、`T-S031`、`T-S032` 已完成,当前 BAA 已具备 ChatGPT / Gemini 最终消息 raw relay、`browser.final_message` 最近快照保留、conductor 侧 instruction center Phase 1 最小闭环与 live ingest,以及 artifact / manifest / delivery plan 服务端核心
42+- `T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 已完成,当前 BAA 已具备 ChatGPT / Gemini 最终消息 raw relay、`browser.final_message` 最近快照保留、conductor 侧 instruction center Phase 1 最小闭环与 live ingest、dedupe / journal 本地持久化,以及 artifact / manifest / delivery plan / upload receipt barrier / inject / send 主链
43 - 当前 BAA 仍保留这些边界:
44   - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面 payload 形态变化,需要同步修改提取器
45   - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
46-  - live message dedupe 和 instruction dedupe 目前都还是进程内内存态,重启后不会保留
47-  - 当前摘要只保留最近一次 live ingest / execute,不落库
48-  - 当前仍只允许 Phase 1 精确 target `conductor` / `system`;service-side artifact center core 已有,但 live 路径还没有把执行结果真正接到 artifact / upload / inject / send
49+  - 插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
50+  - artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
51+  - 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
52+  - live message dedupe 和 instruction dedupe 当前已做成单节点本地持久化,但不做跨节点共享
53+  - execution journal 当前只保留最近窗口,不扩成无限历史审计
54+  - 当前 live 执行路径仍只覆盖 Phase 1 精确 target `conductor` / `system`,不扩到跨节点或完整 task/run 编排
55 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
56 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界
57 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
M plugins/baa-firefox/content-script.js
+378, -0
  1@@ -5,6 +5,378 @@ function sendBridgeMessage(type, data) {
  2   }).catch(() => {});
  3 }
  4 
  5+const DELIVERY_POLL_INTERVAL_MS = 200;
  6+const DELIVERY_TIMEOUT_MS = 30_000;
  7+const ATTACHMENT_PROGRESS_SELECTORS = [
  8+  "[role='progressbar']",
  9+  "[aria-busy='true']",
 10+  "progress",
 11+  "[class*='upload'][class*='progress']",
 12+  "[class*='spinner']"
 13+];
 14+const PLATFORM_DELIVERY_CONFIG = {
 15+  claude: {
 16+    composerSelectors: [
 17+      "div[contenteditable='true'][data-testid*='composer']",
 18+      "div[contenteditable='true'][aria-label*='message' i]",
 19+      "div[contenteditable='true'][role='textbox']",
 20+      "div.ProseMirror[contenteditable='true']",
 21+      "textarea"
 22+    ],
 23+    fileInputSelectors: [
 24+      "input[type='file']:not([disabled])"
 25+    ],
 26+    sendButtonSelectors: [
 27+      "button[data-testid*='send' i]",
 28+      "button[aria-label*='send' i]",
 29+      "form button[type='submit']"
 30+    ]
 31+  },
 32+  chatgpt: {
 33+    composerSelectors: [
 34+      "#prompt-textarea",
 35+      "textarea[data-testid='prompt-textarea']",
 36+      "div[contenteditable='true'][data-testid='prompt-textarea']",
 37+      "div[contenteditable='true'][role='textbox']",
 38+      "textarea"
 39+    ],
 40+    fileInputSelectors: [
 41+      "input[type='file']:not([disabled])"
 42+    ],
 43+    sendButtonSelectors: [
 44+      "button[data-testid='send-button']",
 45+      "button[aria-label*='send prompt' i]",
 46+      "button[aria-label*='send message' i]",
 47+      "form button[type='submit']"
 48+    ]
 49+  }
 50+};
 51+
 52+function trimToNull(value) {
 53+  if (typeof value !== "string") {
 54+    return null;
 55+  }
 56+
 57+  const normalized = value.trim();
 58+  return normalized === "" ? null : normalized;
 59+}
 60+
 61+function sleep(ms) {
 62+  return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
 63+}
 64+
 65+function getDeliveryConfig(platform) {
 66+  return platform ? PLATFORM_DELIVERY_CONFIG[platform] || null : null;
 67+}
 68+
 69+function isElementVisible(element, options = {}) {
 70+  if (!(element instanceof Element)) {
 71+    return false;
 72+  }
 73+
 74+  if (options.allowHidden === true) {
 75+    return true;
 76+  }
 77+
 78+  const style = globalThis.getComputedStyle(element);
 79+  if (!style) {
 80+    return true;
 81+  }
 82+
 83+  if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity || "1") === 0) {
 84+    return false;
 85+  }
 86+
 87+  const rect = element.getBoundingClientRect();
 88+  return rect.width > 0 && rect.height > 0;
 89+}
 90+
 91+function queryFirst(selectors, options = {}) {
 92+  for (const selector of selectors) {
 93+    const matches = document.querySelectorAll(selector);
 94+
 95+    for (const element of matches) {
 96+      if (isElementVisible(element, options)) {
 97+        return element;
 98+      }
 99+    }
100+  }
101+
102+  return null;
103+}
104+
105+function normalizeText(text) {
106+  return String(text || "")
107+    .replace(/\s+/gu, " ")
108+    .trim()
109+    .toLowerCase();
110+}
111+
112+function elementDisabled(element) {
113+  return element instanceof HTMLButtonElement || element instanceof HTMLInputElement
114+    ? element.disabled
115+    : element.getAttribute("aria-disabled") === "true";
116+}
117+
118+function countAttachmentProgressIndicators() {
119+  return ATTACHMENT_PROGRESS_SELECTORS.reduce(
120+    (count, selector) => count + document.querySelectorAll(selector).length,
121+    0
122+  );
123+}
124+
125+function readComposerText(element) {
126+  if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) {
127+    return element.value || "";
128+  }
129+
130+  return element.textContent || "";
131+}
132+
133+function dispatchInputEvents(element) {
134+  element.dispatchEvent(new InputEvent("input", {
135+    bubbles: true,
136+    cancelable: true,
137+    data: readComposerText(element),
138+    inputType: "insertText"
139+  }));
140+  element.dispatchEvent(new Event("change", {
141+    bubbles: true
142+  }));
143+}
144+
145+function setNativeValue(element, value) {
146+  if (element instanceof HTMLTextAreaElement) {
147+    const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value");
148+    descriptor?.set?.call(element, value);
149+    return;
150+  }
151+
152+  if (element instanceof HTMLInputElement) {
153+    const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
154+    descriptor?.set?.call(element, value);
155+  }
156+}
157+
158+function setComposerText(element, text) {
159+  if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) {
160+    element.focus();
161+    setNativeValue(element, text);
162+    dispatchInputEvents(element);
163+    return;
164+  }
165+
166+  if (!(element instanceof HTMLElement) || element.isContentEditable !== true) {
167+    throw new Error("page composer is not editable");
168+  }
169+
170+  element.focus();
171+
172+  if (typeof document.execCommand === "function") {
173+    try {
174+      document.execCommand("selectAll", false);
175+      document.execCommand("insertText", false, text);
176+      dispatchInputEvents(element);
177+      return;
178+    } catch (_) {}
179+  }
180+
181+  element.textContent = text;
182+  dispatchInputEvents(element);
183+}
184+
185+function setInputFiles(input, files) {
186+  const transfer = new DataTransfer();
187+
188+  for (const file of files) {
189+    transfer.items.add(file);
190+  }
191+
192+  const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "files");
193+  descriptor?.set?.call(input, transfer.files);
194+  input.dispatchEvent(new Event("input", {
195+    bubbles: true
196+  }));
197+  input.dispatchEvent(new Event("change", {
198+    bubbles: true
199+  }));
200+}
201+
202+async function waitFor(predicate, timeoutMs = DELIVERY_TIMEOUT_MS, intervalMs = DELIVERY_POLL_INTERVAL_MS) {
203+  const startedAt = Date.now();
204+  let lastError = null;
205+
206+  while (Date.now() - startedAt <= timeoutMs) {
207+    try {
208+      const result = await predicate();
209+
210+      if (result) {
211+        return result;
212+      }
213+    } catch (error) {
214+      lastError = error;
215+    }
216+
217+    await sleep(intervalMs);
218+  }
219+
220+  throw lastError || new Error("delivery command timed out");
221+}
222+
223+function findComposer(platform) {
224+  const config = getDeliveryConfig(platform);
225+  return config ? queryFirst(config.composerSelectors) : null;
226+}
227+
228+function findFileInput(platform) {
229+  const config = getDeliveryConfig(platform);
230+  return config ? queryFirst(config.fileInputSelectors, {
231+    allowHidden: true
232+  }) : null;
233+}
234+
235+function findSendButton(platform) {
236+  const config = getDeliveryConfig(platform);
237+
238+  if (!config) {
239+    return null;
240+  }
241+
242+  const candidate = queryFirst(config.sendButtonSelectors);
243+
244+  if (candidate && !elementDisabled(candidate)) {
245+    return candidate;
246+  }
247+
248+  return null;
249+}
250+
251+function attachmentLooksReady(filename, input, baselineProgress) {
252+  const normalizedFilename = normalizeText(filename);
253+  const pageText = normalizeText(document.body?.innerText || "");
254+
255+  if (normalizedFilename && pageText.includes(normalizedFilename)) {
256+    return filename;
257+  }
258+
259+  if (
260+    input instanceof HTMLInputElement
261+    && input.files?.length === 1
262+    && normalizeText(input.files[0]?.name || "") === normalizedFilename
263+    && countAttachmentProgressIndicators() <= baselineProgress
264+  ) {
265+    return filename;
266+  }
267+
268+  return null;
269+}
270+
271+async function handleUploadArtifact(command) {
272+  const platform = trimToNull(command?.platform);
273+  const filename = trimToNull(command?.filename);
274+  const mimeType = trimToNull(command?.mimeType) || "application/octet-stream";
275+
276+  if (!platform || !getDeliveryConfig(platform)) {
277+    throw new Error(`unsupported delivery platform: ${platform || "-"}`);
278+  }
279+
280+  if (!filename) {
281+    throw new Error("artifact filename is required");
282+  }
283+
284+  if (!(command?.bytes instanceof ArrayBuffer)) {
285+    throw new Error("artifact bytes must be an ArrayBuffer");
286+  }
287+
288+  const input = await waitFor(() => findFileInput(platform));
289+  const baselineProgress = countAttachmentProgressIndicators();
290+  const file = new File([command.bytes], filename, {
291+    lastModified: Date.now(),
292+    type: mimeType
293+  });
294+
295+  setInputFiles(input, [file]);
296+
297+  const remoteHandle = await waitFor(
298+    () => attachmentLooksReady(filename, input, baselineProgress),
299+    Number(command?.timeoutMs) || DELIVERY_TIMEOUT_MS
300+  );
301+
302+  return {
303+    ok: true,
304+    remoteHandle
305+  };
306+}
307+
308+async function handleInjectMessage(command) {
309+  const platform = trimToNull(command?.platform);
310+  const text = trimToNull(command?.text);
311+
312+  if (!platform || !getDeliveryConfig(platform)) {
313+    throw new Error(`unsupported delivery platform: ${platform || "-"}`);
314+  }
315+
316+  if (!text) {
317+    throw new Error("message text is required");
318+  }
319+
320+  const composer = await waitFor(() => findComposer(platform));
321+  setComposerText(composer, text);
322+  await waitFor(() => normalizeText(readComposerText(composer)).includes(normalizeText(text)), 5_000);
323+
324+  return {
325+    ok: true
326+  };
327+}
328+
329+async function handleSendMessage(command) {
330+  const platform = trimToNull(command?.platform);
331+
332+  if (!platform || !getDeliveryConfig(platform)) {
333+    throw new Error(`unsupported delivery platform: ${platform || "-"}`);
334+  }
335+
336+  const button = await waitFor(() => findSendButton(platform));
337+  button.click();
338+  await sleep(150);
339+
340+  return {
341+    ok: true
342+  };
343+}
344+
345+async function handleDeliveryCommand(data = {}) {
346+  const command = trimToNull(data?.command);
347+
348+  if (!command) {
349+    return {
350+      ok: false,
351+      reason: "delivery command is required"
352+    };
353+  }
354+
355+  try {
356+    switch (command) {
357+      case "upload_artifact":
358+        return await handleUploadArtifact(data);
359+      case "inject_message":
360+        return await handleInjectMessage(data);
361+      case "send_message":
362+        return await handleSendMessage(data);
363+      default:
364+        return {
365+          ok: false,
366+          reason: `unsupported delivery command: ${command}`
367+        };
368+    }
369+  } catch (error) {
370+    return {
371+      ok: false,
372+      reason: error instanceof Error ? error.message : String(error)
373+    };
374+  }
375+}
376+
377 sendBridgeMessage("baa_page_bridge_ready", {
378   url: location.href,
379   source: "content-script"
380@@ -32,6 +404,7 @@ window.addEventListener("__baa_proxy_response__", (event) => {
381 
382 browser.runtime.onMessage.addListener((message) => {
383   if (!message || typeof message !== "object") return undefined;
384+
385   if (message.type === "baa_page_proxy_request") {
386     window.dispatchEvent(new CustomEvent("__baa_proxy_request__", {
387       detail: JSON.stringify(message.data || {})
388@@ -44,6 +417,11 @@ browser.runtime.onMessage.addListener((message) => {
389     window.dispatchEvent(new CustomEvent("__baa_proxy_cancel__", {
390       detail: JSON.stringify(message.data || {})
391     }));
392+    return undefined;
393+  }
394+
395+  if (message.type === "baa_delivery_command") {
396+    return handleDeliveryCommand(message.data || {});
397   }
398 
399   return undefined;
M plugins/baa-firefox/controller.js
+277, -0
  1@@ -45,6 +45,9 @@ const SHELL_RUNTIME_HEALTHCHECK_INTERVAL = 30_000;
  2 const CONTROL_STATUS_BODY_LIMIT = 12_000;
  3 const WS_RECONNECT_DELAY = 3_000;
  4 const PROXY_REQUEST_TIMEOUT = 180_000;
  5+const DELIVERY_COMMAND_TIMEOUT = 30_000;
  6+const DELIVERY_UPLOAD_MAX_ATTEMPTS = 2;
  7+const DELIVERY_FETCH_RETRY_DELAY = 500;
  8 const CLAUDE_MESSAGE_LIMIT = 20;
  9 const CLAUDE_TOOL_PLACEHOLDER_RE = /```\n?This block is not supported on your current device yet\.?\n?```/g;
 10 const CLAUDE_THINKING_START_RE = /^(The user|Let me|I need to|I should|I'll|George|User |Looking at|This is a|OK[,.]|Alright|Hmm|Now |Here|So |Wait|Actually|My |Their |His |Her |We |用户|让我|我需要|我来|我想|好的|那|先|接下来)/;
 11@@ -3950,6 +3953,46 @@ function connectWs(options = {}) {
 12       lastError: null
 13     });
 14 
 15+    if (message.type === "browser.upload_artifacts") {
 16+      handleUploadArtifactsCommand(message).catch((error) => {
 17+        const messageText = error instanceof Error ? error.message : String(error);
 18+        addLog("error", `artifact 上传失败:${messageText}`, false);
 19+      });
 20+      return;
 21+    }
 22+
 23+    if (message.type === "browser.inject_message" || message.type === "browser.send_message") {
 24+      const command = message.type === "browser.inject_message" ? "inject_message" : "send_message";
 25+
 26+      runDeliveryAction(message, command).then((result) => {
 27+        sendPluginActionResult(result, {
 28+          action: command,
 29+          commandType: message.type,
 30+          completed: true,
 31+          platform: result.platform,
 32+          requestId: readPluginActionRequestId(message)
 33+        });
 34+      }).catch((error) => {
 35+        const messageText = error instanceof Error ? error.message : String(error);
 36+        addLog("error", `${command} 失败:${messageText}`, false);
 37+        sendPluginActionResult({
 38+          action: command,
 39+          platform: trimToNull(message.platform),
 40+          results: []
 41+        }, {
 42+          accepted: true,
 43+          action: command,
 44+          commandType: message.type,
 45+          completed: true,
 46+          failed: true,
 47+          platform: trimToNull(message.platform),
 48+          reason: messageText,
 49+          requestId: readPluginActionRequestId(message)
 50+        });
 51+      });
 52+      return;
 53+    }
 54+
 55     const pluginAction = extractPluginManagementMessage(message);
 56     if (pluginAction) {
 57       runPluginManagementAction(pluginAction.action, {
 58@@ -4921,6 +4964,240 @@ async function postProxyRequestToTab(tabId, data) {
 59   throw lastError || new Error("无法连接内容脚本");
 60 }
 61 
 62+function decodeBase64ToArrayBuffer(value) {
 63+  const decoded = globalThis.atob(String(value || ""));
 64+  const bytes = new Uint8Array(decoded.length);
 65+
 66+  for (let index = 0; index < decoded.length; index += 1) {
 67+    bytes[index] = decoded.charCodeAt(index);
 68+  }
 69+
 70+  return bytes.buffer;
 71+}
 72+
 73+async function sendDeliveryCommandToTab(tabId, data) {
 74+  let lastError = null;
 75+
 76+  for (let attempt = 0; attempt < PROXY_MESSAGE_RETRY; attempt += 1) {
 77+    try {
 78+      return await browser.tabs.sendMessage(tabId, {
 79+        type: "baa_delivery_command",
 80+        data
 81+      });
 82+    } catch (error) {
 83+      lastError = error;
 84+
 85+      if (attempt < PROXY_MESSAGE_RETRY - 1) {
 86+        await sleep(PROXY_MESSAGE_RETRY_DELAY);
 87+      }
 88+    }
 89+  }
 90+
 91+  throw lastError || new Error("无法连接内容脚本");
 92+}
 93+
 94+async function fetchDeliveryArtifactPayload(upload) {
 95+  const downloadUrl = trimToNull(upload?.download_url || upload?.downloadUrl);
 96+
 97+  if (!downloadUrl) {
 98+    throw new Error("artifact download_url 缺失");
 99+  }
100+
101+  const response = await fetch(downloadUrl, {
102+    headers: {
103+      accept: "application/json"
104+    }
105+  });
106+
107+  if (!response.ok) {
108+    throw new Error(`artifact payload 拉取失败 (${response.status})`);
109+  }
110+
111+  const payload = await response.json();
112+  const data = payload?.ok === true ? payload.data : null;
113+  const base64 = trimToNull(data?.content_base64 || data?.contentBase64);
114+
115+  if (!data || !base64) {
116+    throw new Error("artifact payload 响应缺少 content_base64");
117+  }
118+
119+  return {
120+    artifactId: trimToNull(data.artifact_id || data.artifactId || upload?.artifact_id || upload?.artifactId),
121+    bytes: decodeBase64ToArrayBuffer(base64),
122+    filename: trimToNull(data.filename || upload?.filename) || "artifact.bin",
123+    mimeType: trimToNull(data.mime_type || data.mimeType || upload?.mime_type || upload?.mimeType) || "application/octet-stream"
124+  };
125+}
126+
127+async function resolveDeliveryTab(platform) {
128+  if (!platform || !["claude", "chatgpt"].includes(platform)) {
129+    throw new Error(`当前 delivery 仅覆盖 claude/chatgpt,收到:${platform || "-"}`);
130+  }
131+
132+  const tab = await ensurePlatformTab(platform, {
133+    action: "delivery_bridge",
134+    focus: true,
135+    reason: "ws_delivery_command",
136+    source: "ws_delivery_bridge"
137+  });
138+
139+  if (!tab?.id) {
140+    throw new Error(`无法定位 ${platformLabel(platform)} shell tab`);
141+  }
142+
143+  await sleep(150);
144+  return tab;
145+}
146+
147+function buildDeliveryShellRuntime(platform) {
148+  try {
149+    return buildPlatformRuntimeSnapshot(platform);
150+  } catch (_) {
151+    return null;
152+  }
153+}
154+
155+function sendUploadReceipt(planId, platform, receipt) {
156+  return wsSend({
157+    type: "browser.upload_receipt",
158+    plan_id: planId,
159+    platform,
160+    receipts: [
161+      {
162+        artifact_id: receipt.artifactId,
163+        attempts: receipt.attempts,
164+        error: receipt.error || null,
165+        ok: receipt.ok === true,
166+        received_at: Date.now(),
167+        remote_handle: receipt.remoteHandle || null
168+      }
169+    ]
170+  });
171+}
172+
173+async function performArtifactUpload(planId, platform, tabId, upload) {
174+  let lastError = null;
175+
176+  for (let attempt = 1; attempt <= DELIVERY_UPLOAD_MAX_ATTEMPTS; attempt += 1) {
177+    try {
178+      const artifact = await fetchDeliveryArtifactPayload(upload);
179+      const result = await sendDeliveryCommandToTab(tabId, {
180+        artifactId: artifact.artifactId,
181+        bytes: artifact.bytes,
182+        command: "upload_artifact",
183+        filename: artifact.filename,
184+        mimeType: artifact.mimeType,
185+        platform,
186+        planId,
187+        timeoutMs: DELIVERY_COMMAND_TIMEOUT
188+      });
189+
190+      if (result?.ok === true) {
191+        return {
192+          artifactId: artifact.artifactId,
193+          attempts: attempt,
194+          error: null,
195+          ok: true,
196+          remoteHandle: trimToNull(result.remoteHandle) || artifact.filename
197+        };
198+      }
199+
200+      lastError = trimToNull(result?.reason) || `artifact ${artifact.filename} upload failed`;
201+    } catch (error) {
202+      lastError = error instanceof Error ? error.message : String(error);
203+    }
204+
205+    if (attempt < DELIVERY_UPLOAD_MAX_ATTEMPTS) {
206+      await sleep(DELIVERY_FETCH_RETRY_DELAY);
207+    }
208+  }
209+
210+  return {
211+    artifactId: trimToNull(upload?.artifact_id || upload?.artifactId),
212+    attempts: DELIVERY_UPLOAD_MAX_ATTEMPTS,
213+    error: lastError || "artifact upload failed",
214+    ok: false,
215+    remoteHandle: null
216+  };
217+}
218+
219+async function handleUploadArtifactsCommand(message) {
220+  const platform = trimToNull(message?.platform);
221+  const planId = trimToNull(message?.plan_id || message?.planId);
222+  const uploads = Array.isArray(message?.uploads) ? message.uploads : [];
223+
224+  if (!platform) {
225+    throw new Error("browser.upload_artifacts 缺少 platform");
226+  }
227+
228+  if (!planId) {
229+    throw new Error("browser.upload_artifacts 缺少 plan_id");
230+  }
231+
232+  if (uploads.length === 0) {
233+    throw new Error("browser.upload_artifacts 缺少 uploads");
234+  }
235+
236+  addLog("info", `开始上传 ${uploads.length} 个 ${platformLabel(platform)} artifact`, false);
237+  const tab = await resolveDeliveryTab(platform);
238+
239+  for (const upload of uploads) {
240+    const receipt = await performArtifactUpload(planId, platform, tab.id, upload);
241+    const accepted = sendUploadReceipt(planId, platform, receipt);
242+
243+    if (!accepted) {
244+      throw new Error("上传回执发送失败:本地 WS 未连接");
245+    }
246+
247+    if (receipt.ok !== true) {
248+      throw new Error(receipt.error || "artifact upload failed");
249+    }
250+  }
251+
252+  addLog("info", `${platformLabel(platform)} artifact 上传已确认`, false);
253+}
254+
255+async function runDeliveryAction(message, command) {
256+  const platform = trimToNull(message?.platform);
257+  const planId = trimToNull(message?.plan_id || message?.planId);
258+
259+  if (!platform) {
260+    throw new Error(`${command} 缺少 platform`);
261+  }
262+
263+  if (!planId) {
264+    throw new Error(`${command} 缺少 plan_id`);
265+  }
266+
267+  const tab = await resolveDeliveryTab(platform);
268+  const payload = {
269+    command,
270+    platform,
271+    planId,
272+    text: command === "inject_message"
273+      ? trimToNull(message?.message_text || message?.messageText)
274+      : null
275+  };
276+  const result = await sendDeliveryCommandToTab(tab.id, payload);
277+
278+  if (result?.ok !== true) {
279+    throw new Error(trimToNull(result?.reason) || `${command} failed`);
280+  }
281+
282+  return {
283+    action: command,
284+    platform,
285+    results: [
286+      {
287+        ok: true,
288+        platform,
289+        shell_runtime: buildDeliveryShellRuntime(platform),
290+        tabId: tab.id
291+      }
292+    ]
293+  };
294+}
295+
296 async function proxyApiRequest(message) {
297   const { id, platform, method = "GET", path: apiPath, body = null } = message || {};
298   if (!id) throw new Error("缺少代理请求 ID");
M tasks/T-S031.md
+6, -6
 1@@ -152,11 +152,11 @@
 2   - `browser.final_message` 已接到 conductor 侧 live ingest 路径,并进入 `BaaInstructionCenter`
 3   - `/v1/browser` 与 Firefox WS `state_snapshot.snapshot.browser.instruction_ingest` 已暴露最近一次 live ingest / execute 摘要
 4   - automated test 已覆盖:普通消息忽略、replay 去重、缺少 `conversation_id` 可容忍、live final message 触发执行
 5-- 当前残余风险:
 6-  - live message dedupe 和 instruction dedupe 目前都还是进程内内存态,重启后不会保留
 7-  - 摘要当前只保留最近一次 live ingest / execute,不落库
 8-  - 当前仍只允许 Phase 1 精确 target `conductor` / `system`,且 live 路径还没有接 artifact / upload / inject / send
 9+- 后续 `T-S033`、`T-S034` 已继续收口上述缺口;当前统一口径下仍保留的边界是:
10+  - live message dedupe 和 instruction dedupe 当前已做成单节点本地持久化,但不做跨节点共享
11+  - execution journal 当前只保留最近窗口,不扩成无限历史审计
12+  - 当前 live 执行路径仍只覆盖 Phase 1 精确 target `conductor` / `system`,不扩到跨节点或完整 task/run 编排
13 - 实际验证:
14   - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`:通过
15-  - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`44/44` 通过
16-  - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`:`6/6` 通过
17+  - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`45/45` 通过
18+  - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`:`8/8` 通过
M tasks/T-S032.md
+6, -4
 1@@ -160,13 +160,15 @@
 2   - `files/write`
 3   - `describe` / `describe/business` / `describe/control`
 4   - `status`
 5-- 当前明确留到后续:
 6+- 完成当时明确留到后续:
 7   - Firefox 插件 upload / download
 8   - upload receipt barrier
 9   - 依赖 receipt 的自动 inject / send
10 - 当前残余边界:
11-  - service-side artifact center core 已落地,但 live instruction ingest 路径还没有把执行结果真正接到 artifact / upload / inject / send
12-  - 当前仍只服务于 Phase 1 本机精确 target,不涉及跨节点和多轮闭环
13+  - 后续 `T-S034` 已把 live instruction ingest 路径接到 artifact / upload / inject / send
14+  - 当前交付仍建立在单节点本地 artifact store 和本地 `download_url` 之上,不做跨节点分发
15+  - execution journal 只保留最近窗口,不扩成无限历史审计
16+  - 当前 live 执行路径仍只覆盖 Phase 1 本机精确 target,不涉及跨节点和完整 task/run 编排
17 - 实际验证:
18   - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`:通过
19-  - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`44/44` 通过
20+  - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`45/45` 通过
A tasks/T-S033.md
+167, -0
  1@@ -0,0 +1,167 @@
  2+# Task T-S033:补 BAA dedupe 与 execution journal 持久化
  3+
  4+## 直接给对话的提示词
  5+
  6+读 `/Users/george/code/baa-conductor/tasks/T-S033.md` 任务文档,完成开发任务。
  7+
  8+如需补背景,再读:
  9+
 10+- `/Users/george/code/baa-conductor/plans/BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md`
 11+- `/Users/george/code/baa-conductor/plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`
 12+- `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`
 13+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/04-execution-loop-state-machine.md`
 14+
 15+## 当前基线
 16+
 17+- 仓库:`/Users/george/code/baa-conductor`
 18+- 分支:`main`
 19+- 提交:`5cdeb5d`
 20+- 开工要求:如需新分支,从当前 `main` 新切
 21+
 22+## 建议分支名
 23+
 24+- `feat/baa-execution-persistence`
 25+
 26+## 目标
 27+
 28+把 live message dedupe、instruction dedupe 和最近执行摘要从进程内内存态补成单节点可恢复的持久化状态。
 29+
 30+## 背景
 31+
 32+`T-S031` 已经打通 live ingest,但当前仍有两个实际风险:
 33+
 34+- dedupe 重启后丢失
 35+- 最近摘要只保留最近一次,不落库
 36+
 37+如果不补这层,系统重启后会重复执行已处理过的 final message,诊断面也不完整。
 38+
 39+## 涉及仓库
 40+
 41+- `/Users/george/code/baa-conductor`
 42+
 43+## 范围
 44+
 45+- live message dedupe 持久化
 46+- instruction dedupe 持久化
 47+- execution / ingest journal 持久化
 48+- `/v1/browser` 最近摘要历史读面
 49+- 自动化测试与文档回写
 50+
 51+## 路径约束
 52+
 53+- 首版只做单节点持久化
 54+- 不要在这张卡里顺手做 artifact upload / inject / send
 55+- 不要把这张卡扩成 task/run 系统
 56+
 57+## 推荐实现边界
 58+
 59+建议新增:
 60+
 61+- `apps/conductor-daemon/src/instructions/store.ts` 或等价持久化封装
 62+- 最近摘要历史结构与读面适配
 63+
 64+建议放到:
 65+
 66+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/instructions/`
 67+- `/Users/george/code/baa-conductor/packages/db/`
 68+
 69+## 允许修改的目录
 70+
 71+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/`
 72+- `/Users/george/code/baa-conductor/packages/db/`
 73+- `/Users/george/code/baa-conductor/tasks/`
 74+- `/Users/george/code/baa-conductor/plans/`
 75+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/`
 76+
 77+## 尽量不要修改
 78+
 79+- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
 80+- `/Users/george/code/baa-conductor/tests/browser/`
 81+- `/Users/george/code/baa-conductor/apps/status-api/`
 82+
 83+## 必须完成
 84+
 85+### 1. 持久化 dedupe 状态
 86+
 87+- live message dedupe 重启后仍能生效
 88+- instruction dedupe 重启后仍能生效
 89+
 90+### 2. 持久化 execution journal
 91+
 92+- ingest / execute 摘要不再只保留最后一次
 93+- 重启后仍能读取最近若干条摘要
 94+
 95+### 3. 补回归测试和读面
 96+
 97+- 至少补重启后不重复执行的测试
 98+- `/v1/browser` 或等价读面要能读最近历史
 99+
100+## 需要特别注意
101+
102+- 不要把 journal 做成无限增长的无边界存储
103+- 不要为了持久化改掉当前 Phase 1 exact target 规则
104+- 和 `T-S034` 并行时,不要去实现 upload / inject / send
105+
106+## 验收标准
107+
108+- 重启后同一条已处理 final message 不会再次执行
109+- 最近摘要历史重启后仍可读
110+- `git diff --check` 通过
111+
112+## 评测要求
113+
114+### 1. 正向评测
115+
116+- 执行过一次的 final message 在重启后仍被识别为 duplicate
117+- 最近若干条 ingest / execute 摘要可正常读取
118+
119+### 2. 反向评测
120+
121+- 新消息不能被错误判成历史 duplicate
122+- journal 读面不能把空记录误报成成功执行
123+
124+### 3. 边界评测
125+
126+- 缺少 `conversation_id` 的消息身份仍能持久化去重
127+- journal 条数达到上限时不能崩,且淘汰顺序稳定
128+
129+## 推荐验证命令
130+
131+- `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
132+- `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
133+- `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
134+- `git -C /Users/george/code/baa-conductor diff --check`
135+
136+## 交付要求
137+
138+完成后请说明:
139+
140+- 修改了哪些文件
141+- dedupe 和 journal 是怎么持久化的
142+- 最近历史读面放在了哪里
143+- 跑了哪些测试
144+- 还有哪些剩余风险
145+
146+## 完成记录(2026-03-27)
147+
148+- 已完成:
149+  - 新增 control-plane sqlite 持久化表:
150+    - `baa_message_dedupes`
151+    - `baa_instruction_dedupes`
152+    - `baa_execution_journal`
153+  - live message dedupe 与 instruction dedupe 都已改为 repo-backed 持久化
154+  - ingest / execute 摘要已写入 bounded journal,并在 daemon 启动时恢复
155+  - `GET /v1/browser` 与 Firefox WS `state_snapshot.snapshot.browser.instruction_ingest` 已暴露:
156+    - `last_ingest`
157+    - `last_execute`
158+    - `recent_ingests`
159+    - `recent_executes`
160+  - automated test 已覆盖:
161+    - 重启后 final message 仍判定为 duplicate
162+    - `/v1/browser` 重启后可读最近历史
163+    - bounded prune 顺序稳定
164+
165+- 当前残余风险:
166+  - dedupe 与 journal 仍是单节点本地持久化,不做跨节点共享
167+  - journal 采用固定上限淘汰,只保留最近窗口
168+  - 当前 live 执行路径仍只覆盖 Phase 1 exact target,未扩展到 upload / inject / send 编排
A tasks/T-S034.md
+173, -0
  1@@ -0,0 +1,173 @@
  2+# Task T-S034:打通 BAA delivery bridge、upload receipt barrier 与 inject/send
  3+
  4+## 直接给对话的提示词
  5+
  6+读 `/Users/george/code/baa-conductor/tasks/T-S034.md` 任务文档,完成开发任务。
  7+
  8+如需补背景,再读:
  9+
 10+- `/Users/george/code/baa-conductor/plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`
 11+- `/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`
 12+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md`
 13+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/09-artifact-delivery-thin-plugin.md`
 14+
 15+## 当前基线
 16+
 17+- 仓库:`/Users/george/code/baa-conductor`
 18+- 分支:`main`
 19+- 提交:`5cdeb5d`
 20+- 开工要求:如需新分支,从当前 `main` 新切
 21+
 22+## 建议分支名
 23+
 24+- `feat/baa-delivery-bridge`
 25+
 26+## 目标
 27+
 28+把 service-side artifact core 真正接到 Firefox 插件 delivery bridge,补齐 upload_artifacts / upload_receipt barrier / inject_message / send_message 主链。
 29+
 30+## 背景
 31+
 32+`T-S032` 已经补上 artifact center core,但 live 路径还没有真正进入 artifact / upload / inject / send 闭环。
 33+
 34+这导致当前 artifact core 只能生成 plan,不能形成真实交付。
 35+
 36+## 涉及仓库
 37+
 38+- `/Users/george/code/baa-conductor`
 39+
 40+## 范围
 41+
 42+- conductor upload session / receipt barrier
 43+- Firefox 插件 upload / inject / send 执行
 44+- WS 消息合同与状态回写
 45+- 自动化 smoke 与文档回写
 46+
 47+## 路径约束
 48+
 49+- 首版不要求做 artifact download
 50+- 首版可先面向单客户端、单轮 delivery
 51+- 不要把这张卡扩成 ChatGPT / Gemini 正式 `browser.*` target 路由
 52+
 53+## 推荐实现边界
 54+
 55+建议新增:
 56+
 57+- `apps/conductor-daemon/src/artifacts/upload-session.ts`
 58+- `plugins/baa-firefox/` 下最小 delivery bridge 处理
 59+
 60+建议放到:
 61+
 62+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/artifacts/`
 63+- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
 64+
 65+## 允许修改的目录
 66+
 67+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/`
 68+- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
 69+- `/Users/george/code/baa-conductor/tests/browser/`
 70+- `/Users/george/code/baa-conductor/docs/firefox/`
 71+- `/Users/george/code/baa-conductor/docs/api/`
 72+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/`
 73+- `/Users/george/code/baa-conductor/tasks/`
 74+- `/Users/george/code/baa-conductor/plans/`
 75+
 76+## 尽量不要修改
 77+
 78+- `/Users/george/code/baa-conductor/packages/db/`
 79+- `/Users/george/code/baa-conductor/apps/status-api/`
 80+
 81+## 必须完成
 82+
 83+### 1. 打通 upload / receipt barrier
 84+
 85+- conductor 能发出 `browser.upload_artifacts`
 86+- 插件能回 `browser.upload_receipt`
 87+- receipt 未完成前不能提前 inject / send
 88+
 89+### 2. 打通 inject / send
 90+
 91+- receipt 成功后插件能注入准备好的 index text
 92+- 在允许时能执行 send
 93+
 94+### 3. 补失败与降级
 95+
 96+- 上传失败有明确重试或降级
 97+- 不能出现“没有 receipt 但已被视为成功发送”
 98+
 99+## 需要特别注意
100+
101+- 插件保持薄层,不做 artifact 打包或 parser
102+- 不要把这张卡扩成 artifact download
103+- 和 `T-S033` 并行时,不要去改 dedupe/journal 持久化主逻辑
104+
105+## 验收标准
106+
107+- upload_artifacts / upload_receipt / inject_message / send_message 主链打通
108+- receipt barrier 生效
109+- 失败路径有明确结果,不会静默成功
110+- `git diff --check` 通过
111+
112+## 评测要求
113+
114+### 1. 正向评测
115+
116+- artifact upload 成功后,index text 才被 inject
117+- auto-send 允许时,send 在 inject 之后发生
118+
119+### 2. 反向评测
120+
121+- 没有 receipt 时不能提前 inject / send
122+- 上传失败时不能被误标成已完成发送
123+
124+### 3. 边界评测
125+
126+- 多 artifact 上传时,全部确认后才放行
127+- 单个 artifact 失败时,重试或降级行为稳定
128+
129+## 推荐验证命令
130+
131+- `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
132+- `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
133+- `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
134+- `git -C /Users/george/code/baa-conductor diff --check`
135+
136+## 交付要求
137+
138+完成后请说明:
139+
140+- 修改了哪些文件
141+- upload session / receipt barrier 是怎么设计的
142+- 插件 upload / inject / send 覆盖了哪些平台
143+- 跑了哪些测试
144+- 还有哪些剩余风险
145+
146+## 完成记录(2026-03-27)
147+
148+- 已新增 `apps/conductor-daemon/src/artifacts/upload-session.ts`,把 execution result -> artifact -> manifest -> delivery plan -> upload receipt barrier -> inject/send 串成一条 live session。
149+- 已把 Firefox WS bridge 扩到:
150+  - `browser.upload_artifacts`
151+  - `browser.upload_receipt`
152+  - `browser.inject_message`
153+  - `browser.send_message`
154+- 已新增本地 artifact payload 读取入口:
155+  - `GET /v1/browser/delivery/artifacts/:plan_id/:artifact_id`
156+- Firefox 插件已补最小 delivery 执行:
157+  - 管理页负责按 `download_url` 拉取 artifact payload
158+  - content script 负责单 artifact 上传、文本注入、点击发送
159+  - 当前首版覆盖 `Claude` / `ChatGPT`
160+- 已补 fail-closed 行为:
161+  - receipt 未齐前不会 inject / send
162+  - receipt 失败会直接把 session 标成 failed,不会误标 sent
163+  - 单 artifact 失败会按插件侧重试后回失败 receipt
164+- 已补自动化验证:
165+  - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
166+  - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
167+  - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
168+- 当前残余风险:
169+  - 插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
170+  - artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
171+  - 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
172+  - 当前交付仍建立在单节点本地 artifact store 和本地 `download_url` 之上,不做跨节点分发
173+  - execution journal 只保留最近窗口,不扩成无限历史审计
174+  - live 执行路径当前仍只覆盖 Phase 1 精确 target `conductor` / `system`,不扩到跨节点或完整 task/run 编排
M tasks/TASK_OVERVIEW.md
+15, -6
 1@@ -11,11 +11,11 @@
 2 - 当前任务卡都放在本目录
 3 - 浏览器控制主链路收口基线:`main@07895cd`
 4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
 5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032` 落地
 6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 落地
 7 
 8 ## 状态分类
 9 
10-- `已完成`:`T-S001` 到 `T-S032`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
11+- `已完成`:`T-S001` 到 `T-S034`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
12 - `当前 TODO`:
13   - `T-S026` 真实 Firefox 手工 smoke 与验收记录
14 - `待处理缺陷`:当前无 open bug backlog(见 `../bugs/README.md`)
15@@ -23,6 +23,8 @@
16 
17 当前新的主需求文档:
18 
19+- [`../plans/BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md`](../plans/BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md)
20+- [`../plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`](../plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md)
21 - [`../plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`](../plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
22 - [`../plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`](../plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
23 - [`../plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`](../plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
24@@ -70,6 +72,8 @@
25 32. [`T-S030.md`](./T-S030.md):收口 BAA 指令解析中心 Phase 1 与最小执行闭环
26 33. [`T-S031.md`](./T-S031.md):把 live `browser.final_message` 接到 BAA instruction center
27 34. [`T-S032.md`](./T-S032.md):补 BAA artifact center 与 delivery plan Phase 2 服务端核心
28+35. [`T-S033.md`](./T-S033.md):补 BAA dedupe 与 execution journal 持久化
29+36. [`T-S034.md`](./T-S034.md):打通 BAA delivery bridge、upload receipt barrier 与 inject/send
30 
31 ## 已准备的后续任务
32 
33@@ -134,16 +138,21 @@
34 - 当前 open bug backlog 已清空
35 - 当前主线剩余任务是:
36   - [`T-S026.md`](./T-S026.md):补真实 Firefox 手工 smoke 与验收记录
37-- `T-S029`、`T-S030`、`T-S031`、`T-S032` 已完成,当前 BAA 已具备:
38+- `T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 已完成,当前 BAA 已具备:
39   - ChatGPT / Gemini 最终消息 raw relay 与 `browser.final_message` 快照保留
40   - conductor 侧 instruction center Phase 1 最小闭环与 live ingest
41+  - conductor 侧 dedupe 与 execution journal 本地持久化,以及 `/v1/browser` 最近摘要恢复
42   - conductor 侧 artifact / manifest / index text / delivery plan 服务端核心
43+  - delivery bridge、upload receipt barrier、inject / send 的 live 闭环
44 - 当前保留的 BAA 边界是:
45   - ChatGPT 当前主要依赖 conversation SSE 结构;页面 payload 形态变化后需要同步调整提取器
46   - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
47-  - live message dedupe 和 instruction dedupe 目前都还是进程内内存态,重启后不会保留
48-  - 当前摘要只保留最近一次 live ingest / execute,不落库
49-  - 当前仍只允许 Phase 1 精确 target `conductor` / `system`;service-side artifact center core 已有,但 live 路径还没有把执行结果真正接到 artifact / upload / inject / send
50+  - 插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
51+  - artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
52+  - 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
53+  - live message dedupe 和 instruction dedupe 当前已做成单节点本地持久化,但不做跨节点共享
54+  - execution journal 当前只保留最近窗口,不扩成无限历史审计
55+  - 当前 live 执行路径仍只覆盖 Phase 1 精确 target `conductor` / `system`,不扩到跨节点或完整 task/run 编排
56 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
57 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界,不是 regression
58 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
M tests/browser/browser-control-e2e-smoke.test.mjs
+262, -0
  1@@ -146,6 +146,13 @@ async function waitForCondition(assertion, timeoutMs = 2_000, intervalMs = 50) {
  2   throw lastError ?? new Error("timed out waiting for condition");
  3 }
  4 
  5+async function expectQueueTimeout(queue, predicate, timeoutMs = 400) {
  6+  await assert.rejects(
  7+    () => queue.next(predicate, timeoutMs),
  8+    /timed out waiting for websocket message/u
  9+  );
 10+}
 11+
 12 async function connectFirefoxBridgeClient(wsUrl, clientId) {
 13   const socket = new WebSocket(wsUrl);
 14   const queue = createWebSocketMessageQueue(socket);
 15@@ -1244,6 +1251,261 @@ test("browser control e2e smoke covers metadata read surface plus Claude and Cha
 16   }
 17 });
 18 
 19+test("browser delivery bridge waits for all upload receipts before inject and send", async () => {
 20+  const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-smoke-"));
 21+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-host-"));
 22+  const runtime = new ConductorRuntime(
 23+    {
 24+      nodeId: "mini-main",
 25+      host: "mini",
 26+      role: "primary",
 27+      controlApiBase: "https://conductor.example.test",
 28+      localApiBase: "http://127.0.0.1:0",
 29+      sharedToken: "replace-me",
 30+      paths: {
 31+        runsDir: "/tmp/runs",
 32+        stateDir
 33+      }
 34+    },
 35+    {
 36+      autoStartLoops: false,
 37+      now: () => 100
 38+    }
 39+  );
 40+
 41+  let client = null;
 42+
 43+  try {
 44+    const snapshot = await runtime.start();
 45+    const baseUrl = snapshot.controlApi.localApiBase;
 46+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-delivery-smoke");
 47+
 48+    client.socket.send(
 49+      JSON.stringify({
 50+        type: "browser.final_message",
 51+        platform: "chatgpt",
 52+        conversation_id: "conv-delivery-smoke",
 53+        assistant_message_id: "msg-delivery-smoke",
 54+        raw_text: [
 55+          "```baa",
 56+          `@conductor::exec::{"command":"printf 'artifact-one\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
 57+          "```",
 58+          "",
 59+          "```baa",
 60+          `@conductor::exec::{"command":"printf 'artifact-two\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
 61+          "```"
 62+        ].join("\n"),
 63+        observed_at: 1710000010000
 64+      })
 65+    );
 66+
 67+    const uploadMessage = await client.queue.next(
 68+      (message) => message.type === "browser.upload_artifacts"
 69+    );
 70+    assert.equal(uploadMessage.platform, "chatgpt");
 71+    assert.equal(uploadMessage.uploads.length, 3);
 72+
 73+    const firstArtifact = await fetchJson(uploadMessage.uploads[0].download_url);
 74+    assert.equal(firstArtifact.response.status, 200);
 75+    assert.equal(firstArtifact.payload.data.artifact_id, uploadMessage.uploads[0].artifact_id);
 76+    assert.equal(firstArtifact.payload.data.filename, uploadMessage.uploads[0].filename);
 77+    assert.equal(firstArtifact.payload.data.encoding, "base64");
 78+
 79+    await expectQueueTimeout(
 80+      client.queue,
 81+      (message) => message.type === "browser.inject_message"
 82+    );
 83+
 84+    client.socket.send(
 85+      JSON.stringify({
 86+        type: "browser.upload_receipt",
 87+        plan_id: uploadMessage.plan_id,
 88+        receipts: [
 89+          {
 90+            artifact_id: uploadMessage.uploads[0].artifact_id,
 91+            attempts: 1,
 92+            ok: true,
 93+            remote_handle: "remote-file-1"
 94+          }
 95+        ]
 96+      })
 97+    );
 98+
 99+    await expectQueueTimeout(
100+      client.queue,
101+      (message) => message.type === "browser.inject_message"
102+    );
103+
104+    client.socket.send(
105+      JSON.stringify({
106+        type: "browser.upload_receipt",
107+        plan_id: uploadMessage.plan_id,
108+        receipts: uploadMessage.uploads.slice(1).map((upload, index) => ({
109+          artifact_id: upload.artifact_id,
110+          attempts: 1,
111+          ok: true,
112+          remote_handle: `remote-file-${index + 2}`
113+        }))
114+      })
115+    );
116+
117+    const injectMessage = await client.queue.next(
118+      (message) => message.type === "browser.inject_message"
119+    );
120+    assert.equal(injectMessage.platform, "chatgpt");
121+    assert.match(injectMessage.message_text, /\[BAA Result Index\]/u);
122+
123+    await expectQueueTimeout(
124+      client.queue,
125+      (message) => message.type === "browser.send_message"
126+    );
127+
128+    sendPluginActionResult(client.socket, {
129+      action: "inject_message",
130+      commandType: "browser.inject_message",
131+      platform: "chatgpt",
132+      requestId: injectMessage.requestId,
133+      type: "browser.inject_message"
134+    });
135+
136+    const sendMessage = await client.queue.next(
137+      (message) => message.type === "browser.send_message"
138+    );
139+    assert.equal(sendMessage.platform, "chatgpt");
140+    sendPluginActionResult(client.socket, {
141+      action: "send_message",
142+      commandType: "browser.send_message",
143+      platform: "chatgpt",
144+      requestId: sendMessage.requestId,
145+      type: "browser.send_message"
146+    });
147+
148+    const browserStatus = await waitForCondition(async () => {
149+      const result = await fetchJson(`${baseUrl}/v1/browser`);
150+      assert.equal(result.response.status, 200);
151+      assert.equal(result.payload.data.delivery.last_session.stage, "completed");
152+      return result;
153+    });
154+
155+    assert.equal(browserStatus.payload.data.delivery.last_session.receipt_confirmed_count, 3);
156+    assert.deepEqual(browserStatus.payload.data.delivery.last_session.pending_upload_artifact_ids, []);
157+    assert.equal(browserStatus.payload.data.delivery.last_session.upload_receipts.length, 3);
158+    assert.equal(browserStatus.payload.data.delivery.last_session.platform, "chatgpt");
159+  } finally {
160+    client?.queue.stop();
161+    client?.socket.close(1000, "done");
162+    await runtime.stop();
163+    rmSync(stateDir, {
164+      force: true,
165+      recursive: true
166+    });
167+    rmSync(hostOpsDir, {
168+      force: true,
169+      recursive: true
170+    });
171+  }
172+});
173+
174+test("browser delivery bridge fails closed when upload receipts report failure", async () => {
175+  const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-fail-"));
176+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-fail-host-"));
177+  const runtime = new ConductorRuntime(
178+    {
179+      nodeId: "mini-main",
180+      host: "mini",
181+      role: "primary",
182+      controlApiBase: "https://conductor.example.test",
183+      localApiBase: "http://127.0.0.1:0",
184+      sharedToken: "replace-me",
185+      paths: {
186+        runsDir: "/tmp/runs",
187+        stateDir
188+      }
189+    },
190+    {
191+      autoStartLoops: false,
192+      now: () => 100
193+    }
194+  );
195+
196+  let client = null;
197+
198+  try {
199+    const snapshot = await runtime.start();
200+    const baseUrl = snapshot.controlApi.localApiBase;
201+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-delivery-fail");
202+
203+    client.socket.send(
204+      JSON.stringify({
205+        type: "browser.final_message",
206+        platform: "chatgpt",
207+        conversation_id: "conv-delivery-fail",
208+        assistant_message_id: "msg-delivery-fail",
209+        raw_text: [
210+          "```baa",
211+          `@conductor::exec::{"command":"printf 'artifact-fail\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
212+          "```"
213+        ].join("\n"),
214+        observed_at: 1710000020000
215+      })
216+    );
217+
218+    const uploadMessage = await client.queue.next(
219+      (message) => message.type === "browser.upload_artifacts"
220+    );
221+    assert.equal(uploadMessage.uploads.length, 2);
222+
223+    client.socket.send(
224+      JSON.stringify({
225+        type: "browser.upload_receipt",
226+        plan_id: uploadMessage.plan_id,
227+        receipts: [
228+          {
229+            artifact_id: uploadMessage.uploads[0].artifact_id,
230+            attempts: 2,
231+            error: "upload_failed",
232+            ok: false
233+          }
234+        ]
235+      })
236+    );
237+
238+    await expectQueueTimeout(
239+      client.queue,
240+      (message) => message.type === "browser.inject_message",
241+      700
242+    );
243+    await expectQueueTimeout(
244+      client.queue,
245+      (message) => message.type === "browser.send_message",
246+      700
247+    );
248+
249+    const browserStatus = await waitForCondition(async () => {
250+      const result = await fetchJson(`${baseUrl}/v1/browser`);
251+      assert.equal(result.response.status, 200);
252+      assert.equal(result.payload.data.delivery.last_session.stage, "failed");
253+      return result;
254+    });
255+
256+    assert.match(browserStatus.payload.data.delivery.last_session.failed_reason, /upload_failed/u);
257+    assert.equal(browserStatus.payload.data.delivery.last_session.inject_started_at, undefined);
258+    assert.equal(browserStatus.payload.data.delivery.last_session.send_started_at, undefined);
259+  } finally {
260+    client?.queue.stop();
261+    client?.socket.close(1000, "done");
262+    await runtime.stop();
263+    rmSync(stateDir, {
264+      force: true,
265+      recursive: true
266+    });
267+    rmSync(hostOpsDir, {
268+      force: true,
269+      recursive: true
270+    });
271+  }
272+});
273+
274 test("browser control e2e smoke accepts browser.final_message and keeps recent relay snapshots", async () => {
275   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-final-message-smoke-"));
276   const runtime = new ConductorRuntime(