- 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
+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。
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/);
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,
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";
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),
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+}
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+}
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;
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",
+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()
+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(
+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,
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";
+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,
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+}
+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),
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 }
+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
+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
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 满足任一条件应停止自动循环:
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 - 插件很薄
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
+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
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;
+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;
+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+});
+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 }
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 编排
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 编排
+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`
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
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 编排
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 编排
+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` / 状态同步
+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;
+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");
+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` 通过
+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` 通过
+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 编排
+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 编排
+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` / 状态同步
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(