- commit
- 415465b
- parent
- 2c1eb7b
- author
- codex@macbookpro
- date
- 2026-04-01 11:10:40 +0800 CST
feat: isolate baa parse errors per block
7 files changed,
+383,
-54
+120,
-0
1@@ -939,6 +939,7 @@ test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed
2 assert.equal(firstPass.duplicates.length, 0);
3 assert.equal(firstPass.instructions.length, 5);
4 assert.equal(firstPass.executions.length, 5);
5+ assert.equal(firstPass.parseErrors.length, 0);
6
7 const describeExecution = firstPass.executions.find((execution) => execution.tool === "describe");
8 assert.ok(describeExecution);
9@@ -983,6 +984,7 @@ test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed
10 assert.equal(replayPass.status, "duplicate_only");
11 assert.equal(replayPass.duplicates.length, 5);
12 assert.equal(replayPass.executions.length, 0);
13+ assert.equal(replayPass.parseErrors.length, 0);
14 } finally {
15 controlPlane.close();
16 rmSync(hostOpsDir, {
17@@ -1297,6 +1299,57 @@ test("BaaInstructionCenter keeps supported instructions running when one instruc
18 }
19 });
20
21+test("BaaInstructionCenter skips malformed blocks and keeps later valid blocks running", async () => {
22+ const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
23+ const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-parse-isolation-"));
24+ const outputPath = join(hostOpsDir, "after-parse-error.txt");
25+ const center = new BaaInstructionCenter({
26+ localApiContext: {
27+ fetchImpl: globalThis.fetch,
28+ repository,
29+ sharedToken,
30+ snapshotLoader: () => snapshot
31+ }
32+ });
33+ const message = [
34+ "```baa",
35+ '@conductor::files/write::{"path":"broken.txt"',
36+ "```",
37+ "",
38+ "```baa",
39+ `@conductor::files/write::{"path":"after-parse-error.txt","cwd":${JSON.stringify(hostOpsDir)},"content":"still-runs","overwrite":true}`,
40+ "```"
41+ ].join("\n");
42+
43+ try {
44+ const result = await center.processAssistantMessage({
45+ assistantMessageId: "msg-parse-isolation-1",
46+ conversationId: "conv-parse-isolation-1",
47+ platform: "claude",
48+ text: message
49+ });
50+
51+ assert.equal(result.status, "executed");
52+ assert.equal(result.instructions.length, 1);
53+ assert.equal(result.duplicates.length, 0);
54+ assert.equal(result.denied.length, 0);
55+ assert.equal(result.executions.length, 1);
56+ assert.equal(result.parseErrors.length, 1);
57+ assert.equal(result.parseErrors[0].blockIndex, 0);
58+ assert.equal(result.parseErrors[0].stage, "parse");
59+ assert.match(result.parseErrors[0].message, /Failed to parse inline JSON params/i);
60+ assert.equal(result.executions[0].tool, "files/write");
61+ assert.equal(result.executions[0].ok, true);
62+ assert.equal(readFileSync(outputPath, "utf8"), "still-runs");
63+ } finally {
64+ controlPlane.close();
65+ rmSync(hostOpsDir, {
66+ force: true,
67+ recursive: true
68+ });
69+ }
70+});
71+
72 test("BaaInstructionCenter returns denied_only when every pending instruction is denied", async () => {
73 const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
74 const center = new BaaInstructionCenter({
75@@ -1389,6 +1442,7 @@ test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser
76 assert.equal(ignored.summary.status, "ignored_no_instructions");
77 assert.equal(ignored.summary.execution_count, 0);
78 assert.equal(ignored.summary.conversation_id, null);
79+ assert.equal(ignored.summary.parse_error_count, 0);
80
81 const firstPass = await ingest.ingestAssistantFinalMessage({
82 assistantMessageId: "msg-live-exec",
83@@ -1404,6 +1458,7 @@ test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser
84 assert.equal(firstPass.summary.execution_ok_count, 1);
85 assert.equal(firstPass.summary.instruction_tools[0], "conductor::exec");
86 assert.equal(firstPass.summary.conversation_id, null);
87+ assert.equal(firstPass.summary.parse_error_count, 0);
88 assert.equal(readFileSync(dedupeFilePath, "utf8"), "live-hit\n");
89
90 const replayPass = await ingest.ingestAssistantFinalMessage({
91@@ -1417,6 +1472,7 @@ test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser
92
93 assert.equal(replayPass.summary.status, "duplicate_message");
94 assert.equal(replayPass.summary.execution_count, 0);
95+ assert.equal(replayPass.summary.parse_error_count, 0);
96 assert.equal(readFileSync(dedupeFilePath, "utf8"), "live-hit\n");
97
98 const partialDeny = await ingest.ingestAssistantFinalMessage({
99@@ -1440,9 +1496,11 @@ test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser
100 assert.equal(partialDeny.summary.execution_count, 1);
101 assert.equal(partialDeny.summary.execution_ok_count, 1);
102 assert.equal(partialDeny.summary.error_stage, null);
103+ assert.equal(partialDeny.summary.parse_error_count, 0);
104 assert.ok(partialDeny.processResult);
105 assert.equal(partialDeny.processResult.executions.length, 1);
106 assert.equal(partialDeny.processResult.denied.length, 1);
107+ assert.equal(partialDeny.processResult.parseErrors.length, 0);
108 assert.equal(partialDeny.processResult.denied[0].blockIndex, 1);
109 assert.equal(partialDeny.processResult.denied[0].stage, "route");
110 assert.equal(partialDeny.processResult.denied[0].code, null);
111@@ -1467,9 +1525,11 @@ test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser
112 assert.equal(deniedOnly.summary.execution_count, 0);
113 assert.equal(deniedOnly.summary.execution_ok_count, 0);
114 assert.equal(deniedOnly.summary.error_stage, null);
115+ assert.equal(deniedOnly.summary.parse_error_count, 0);
116 assert.ok(deniedOnly.processResult);
117 assert.equal(deniedOnly.processResult.executions.length, 0);
118 assert.equal(deniedOnly.processResult.denied.length, 1);
119+ assert.equal(deniedOnly.processResult.parseErrors.length, 0);
120 assert.equal(deniedOnly.processResult.denied[0].stage, "policy");
121 assert.equal(deniedOnly.processResult.denied[0].code, "unsupported_tool");
122
123@@ -1484,6 +1544,66 @@ test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser
124 }
125 });
126
127+test("BaaLiveInstructionIngest records parse errors while later valid blocks still execute", async () => {
128+ const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
129+ const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-live-instruction-parse-isolation-"));
130+ const outputPath = join(hostOpsDir, "parse-isolation.txt");
131+ const ingest = new BaaLiveInstructionIngest({
132+ localApiContext: {
133+ fetchImpl: globalThis.fetch,
134+ repository,
135+ sharedToken,
136+ snapshotLoader: () => snapshot
137+ },
138+ now: () => 1_710_000_110_000
139+ });
140+
141+ try {
142+ const result = await ingest.ingestAssistantFinalMessage({
143+ assistantMessageId: "msg-live-parse-isolation",
144+ conversationId: "conv-live-parse-isolation",
145+ observedAt: 1_710_000_111_000,
146+ platform: "chatgpt",
147+ source: "browser.final_message",
148+ text: [
149+ "```baa",
150+ '@conductor::files/write::{"path":"broken.txt"',
151+ "```",
152+ "",
153+ "```baa",
154+ `@conductor::exec::{"command":"printf 'parse-live\\n' >> parse-isolation.txt","cwd":${JSON.stringify(hostOpsDir)}}`,
155+ "```"
156+ ].join("\n")
157+ });
158+
159+ assert.equal(result.summary.status, "executed");
160+ assert.equal(result.summary.error_stage, null);
161+ assert.equal(result.summary.parse_error_count, 1);
162+ assert.equal(result.summary.parse_errors.length, 1);
163+ assert.equal(result.summary.parse_errors[0].block_index, 0);
164+ assert.equal(result.summary.parse_errors[0].stage, "parse");
165+ assert.match(result.summary.parse_errors[0].message, /Failed to parse inline JSON params/i);
166+ assert.equal(result.summary.instruction_count, 1);
167+ assert.equal(result.summary.execution_count, 1);
168+ assert.equal(result.summary.execution_ok_count, 1);
169+ assert.ok(result.processResult);
170+ assert.equal(result.processResult.instructions.length, 1);
171+ assert.equal(result.processResult.executions.length, 1);
172+ assert.equal(result.processResult.parseErrors.length, 1);
173+ assert.equal(result.processResult.parseErrors[0].blockIndex, 0);
174+ assert.equal(result.processResult.parseErrors[0].stage, "parse");
175+ assert.equal(readFileSync(outputPath, "utf8"), "parse-live\n");
176+ assert.equal(ingest.getSnapshot().last_ingest?.parse_error_count, 1);
177+ assert.equal(ingest.getSnapshot().last_execute?.parse_error_count, 1);
178+ } finally {
179+ controlPlane.close();
180+ rmSync(hostOpsDir, {
181+ force: true,
182+ recursive: true
183+ });
184+ }
185+});
186+
187 function createManualTimerScheduler() {
188 let now = 0;
189 let nextId = 1;
1@@ -9,7 +9,11 @@ import {
2 type BaaInstructionCenterOptions
3 } from "./loop.js";
4 import type { BaaLiveInstructionSnapshotStore } from "./store.js";
5-import type { BaaInstructionProcessResult, BaaInstructionProcessStatus } from "./types.js";
6+import type {
7+ BaaInstructionParseErrorStage,
8+ BaaInstructionProcessResult,
9+ BaaInstructionProcessStatus
10+} from "./types.js";
11 import { stableStringifyBaaJson } from "./types.js";
12
13 export type BaaLiveInstructionIngestStatus =
14@@ -18,7 +22,8 @@ export type BaaLiveInstructionIngestStatus =
15 | "duplicate_only"
16 | "executed"
17 | "failed"
18- | "ignored_no_instructions";
19+ | "ignored_no_instructions"
20+ | "parse_error_only";
21
22 export interface BaaLiveInstructionIngestInput {
23 assistantMessageId: string;
24@@ -50,11 +55,19 @@ export interface BaaLiveInstructionIngestSummary {
25 instruction_tools: string[];
26 message_dedupe_key: string;
27 observed_at: number | null;
28+ parse_error_count: number;
29+ parse_errors: BaaLiveInstructionParseErrorSummary[];
30 platform: string;
31 source: "browser.final_message";
32 status: BaaLiveInstructionIngestStatus;
33 }
34
35+export interface BaaLiveInstructionParseErrorSummary {
36+ block_index: number;
37+ message: string;
38+ stage: BaaInstructionParseErrorStage;
39+}
40+
41 export interface BaaLiveInstructionIngestSnapshot {
42 last_execute: BaaLiveInstructionIngestSummary | null;
43 last_ingest: BaaLiveInstructionIngestSummary | null;
44@@ -155,6 +168,8 @@ function classifyProcessStatus(status: BaaInstructionProcessStatus): BaaLiveInst
45 return "executed";
46 case "no_instructions":
47 return "ignored_no_instructions";
48+ case "parse_error_only":
49+ return "parse_error_only";
50 }
51 }
52
53@@ -164,6 +179,7 @@ function shouldUpdateExecutionSummary(status: BaaLiveInstructionIngestStatus): b
54 || status === "duplicate_only"
55 || status === "executed"
56 || status === "failed"
57+ || status === "parse_error_only"
58 );
59 }
60
61@@ -188,7 +204,10 @@ function cloneSummary(summary: BaaLiveInstructionIngestSummary): BaaLiveInstruct
62 ...summary,
63 duplicate_tools: [...summary.duplicate_tools],
64 executed_tools: [...summary.executed_tools],
65- instruction_tools: [...summary.instruction_tools]
66+ instruction_tools: [...summary.instruction_tools],
67+ parse_errors: summary.parse_errors.map((entry) => ({
68+ ...entry
69+ }))
70 };
71 }
72
73@@ -312,6 +331,8 @@ export class BaaLiveInstructionIngest {
74 execution_ok_count: 0,
75 instruction_count: 0,
76 instruction_tools: [],
77+ parse_error_count: 0,
78+ parse_errors: [],
79 status: "duplicate_message"
80 };
81 await this.publishSummary("ingest", summary);
82@@ -442,6 +463,19 @@ export class BaaLiveInstructionIngest {
83 >,
84 error: unknown
85 ): BaaLiveInstructionIngestSummary {
86+ const parseErrors =
87+ error instanceof BaaInstructionCenterError
88+ && error.blockIndex != null
89+ && (error.stage === "normalize" || error.stage === "parse")
90+ ? [
91+ {
92+ block_index: error.blockIndex,
93+ message: error.message,
94+ stage: error.stage
95+ } satisfies BaaLiveInstructionParseErrorSummary
96+ ]
97+ : [];
98+
99 return {
100 ...baseSummary,
101 block_count: 0,
102@@ -457,6 +491,8 @@ export class BaaLiveInstructionIngest {
103 execution_ok_count: 0,
104 instruction_count: 0,
105 instruction_tools: [],
106+ parse_error_count: parseErrors.length,
107+ parse_errors: parseErrors,
108 status: "failed"
109 };
110 }
111@@ -474,6 +510,12 @@ export class BaaLiveInstructionIngest {
112 >,
113 processResult: BaaInstructionProcessResult
114 ): BaaLiveInstructionIngestSummary {
115+ const parseErrors = processResult.parseErrors.map((entry) => ({
116+ block_index: entry.blockIndex,
117+ message: entry.message,
118+ stage: entry.stage
119+ }));
120+
121 return {
122 ...baseSummary,
123 block_count: processResult.blocks.length,
124@@ -494,6 +536,8 @@ export class BaaLiveInstructionIngest {
125 instruction_tools: processResult.instructions.map((instruction) =>
126 buildInstructionDescriptor(instruction.target, instruction.tool)
127 ),
128+ parse_error_count: parseErrors.length,
129+ parse_errors: parseErrors,
130 status: classifyProcessStatus(processResult.status)
131 };
132 }
1@@ -3,7 +3,10 @@ import type { ConductorLocalApiContext } from "../local-api.js";
2 import { InMemoryBaaInstructionDeduper, type BaaInstructionDeduper } from "./dedupe.js";
3 import { executeBaaInstruction } from "./executor.js";
4 import { extractBaaInstructionBlocks } from "./extract.js";
5-import { normalizeBaaInstruction } from "./normalize.js";
6+import {
7+ normalizeBaaInstruction,
8+ normalizeBaaInstructionSourceMessage
9+} from "./normalize.js";
10 import { parseBaaInstructionBlock } from "./parse.js";
11 import { evaluateBaaInstructionPolicy } from "./policy.js";
12 import { routeBaaInstruction } from "./router.js";
13@@ -11,6 +14,7 @@ import type {
14 BaaAssistantMessageInput,
15 BaaInstructionDeniedResult,
16 BaaInstructionEnvelope,
17+ BaaInstructionParseErrorRecord,
18 BaaInstructionProcessResult,
19 BaaInstructionRoute
20 } from "./types.js";
21@@ -54,11 +58,28 @@ export class BaaInstructionCenter {
22 duplicates: [],
23 executions: [],
24 instructions: [],
25+ parseErrors: [],
26 status: "no_instructions"
27 };
28 }
29
30- const instructions = this.normalize(input, blocks);
31+ const {
32+ instructions,
33+ parseErrors
34+ } = this.normalize(input, blocks);
35+
36+ if (instructions.length === 0) {
37+ return {
38+ blocks,
39+ denied: [],
40+ duplicates: [],
41+ executions: [],
42+ instructions,
43+ parseErrors,
44+ status: "parse_error_only"
45+ };
46+ }
47+
48 const duplicates: BaaInstructionEnvelope[] = [];
49 const pending: BaaInstructionEnvelope[] = [];
50
51@@ -78,6 +99,7 @@ export class BaaInstructionCenter {
52 duplicates,
53 executions: [],
54 instructions,
55+ parseErrors,
56 status: "duplicate_only"
57 };
58 }
59@@ -100,6 +122,7 @@ export class BaaInstructionCenter {
60 duplicates,
61 executions,
62 instructions,
63+ parseErrors,
64 status: executions.length === 0 && denied.length > 0 ? "denied_only" : "executed"
65 };
66 }
67@@ -113,18 +136,54 @@ export class BaaInstructionCenter {
68 }
69 }
70
71- private normalize(input: BaaAssistantMessageInput, blocks: BaaInstructionProcessResult["blocks"]) {
72+ private normalize(
73+ input: BaaAssistantMessageInput,
74+ blocks: BaaInstructionProcessResult["blocks"]
75+ ): {
76+ instructions: BaaInstructionEnvelope[];
77+ parseErrors: BaaInstructionParseErrorRecord[];
78+ } {
79 try {
80- return blocks.map((block) =>
81- normalizeBaaInstruction(
82- {
83- assistantMessageId: input.assistantMessageId,
84- conversationId: input.conversationId,
85- platform: input.platform
86- },
87- parseBaaInstructionBlock(block)
88- )
89- );
90+ const source = normalizeBaaInstructionSourceMessage({
91+ assistantMessageId: input.assistantMessageId,
92+ conversationId: input.conversationId,
93+ platform: input.platform
94+ });
95+ const instructions: BaaInstructionEnvelope[] = [];
96+ const parseErrors: BaaInstructionParseErrorRecord[] = [];
97+
98+ for (const block of blocks) {
99+ try {
100+ instructions.push(normalizeBaaInstruction(source, parseBaaInstructionBlock(block)));
101+ } catch (error) {
102+ const message = error instanceof Error ? error.message : String(error);
103+ const blockIndex =
104+ error != null &&
105+ typeof error === "object" &&
106+ "blockIndex" in error &&
107+ typeof (error as { blockIndex?: unknown }).blockIndex === "number"
108+ ? (error as { blockIndex: number }).blockIndex
109+ : block.blockIndex;
110+ const stage =
111+ error != null &&
112+ typeof error === "object" &&
113+ "stage" in error &&
114+ (error as { stage?: unknown }).stage === "parse"
115+ ? "parse"
116+ : "normalize";
117+
118+ parseErrors.push({
119+ blockIndex,
120+ message,
121+ stage
122+ });
123+ }
124+ }
125+
126+ return {
127+ instructions,
128+ parseErrors
129+ };
130 } catch (error) {
131 if (error instanceof BaaInstructionCenterError) {
132 throw error;
1@@ -28,15 +28,21 @@ function normalizeOptionalStringField(value: string | null): string | null {
2 return normalized === "" ? null : normalized;
3 }
4
5-export function normalizeBaaInstruction(
6- source: BaaInstructionSourceMessage,
7- instruction: BaaParsedInstruction
8-): BaaInstructionEnvelope {
9- const normalizedSource = {
10+export function normalizeBaaInstructionSourceMessage(
11+ source: BaaInstructionSourceMessage
12+): BaaInstructionSourceMessage {
13+ return {
14 assistantMessageId: requireNonEmptyField("assistantMessageId", source.assistantMessageId),
15 conversationId: normalizeOptionalStringField(source.conversationId),
16 platform: requireNonEmptyField("platform", source.platform)
17 };
18+}
19+
20+export function normalizeBaaInstruction(
21+ source: BaaInstructionSourceMessage,
22+ instruction: BaaParsedInstruction
23+): BaaInstructionEnvelope {
24+ const normalizedSource = normalizeBaaInstructionSourceMessage(source);
25 const dedupeBasis = buildBaaInstructionDedupeBasis(normalizedSource, instruction);
26 const dedupeKey = buildBaaInstructionDedupeKey(dedupeBasis);
27
+103,
-26
1@@ -6,6 +6,7 @@ import {
2 import type { BaaInstructionDeduper } from "./dedupe.js";
3 import type {
4 BaaLiveInstructionIngestSnapshot,
5+ BaaLiveInstructionParseErrorSummary,
6 BaaLiveInstructionIngestSummary,
7 BaaLiveInstructionMessageDeduper
8 } from "./ingest.js";
9@@ -16,7 +17,10 @@ function cloneSummary(summary: BaaLiveInstructionIngestSummary): BaaLiveInstruct
10 ...summary,
11 duplicate_tools: [...summary.duplicate_tools],
12 executed_tools: [...summary.executed_tools],
13- instruction_tools: [...summary.instruction_tools]
14+ instruction_tools: [...summary.instruction_tools],
15+ parse_errors: summary.parse_errors.map((entry) => ({
16+ ...entry
17+ }))
18 };
19 }
20
21@@ -24,40 +28,113 @@ function isStringArray(value: unknown): value is string[] {
22 return Array.isArray(value) && value.every((entry) => typeof entry === "string");
23 }
24
25-function isBaaLiveInstructionIngestSummary(value: unknown): value is BaaLiveInstructionIngestSummary {
26+function isBaaLiveInstructionParseErrorSummary(
27+ value: unknown
28+): value is BaaLiveInstructionParseErrorSummary {
29+ return (
30+ value != null
31+ && typeof value === "object"
32+ && !Array.isArray(value)
33+ && typeof (value as { block_index?: unknown }).block_index === "number"
34+ && typeof (value as { message?: unknown }).message === "string"
35+ && (
36+ (value as { stage?: unknown }).stage === "normalize"
37+ || (value as { stage?: unknown }).stage === "parse"
38+ )
39+ );
40+}
41+
42+function normalizeSummary(value: unknown): BaaLiveInstructionIngestSummary | null {
43 if (value == null || typeof value !== "object" || Array.isArray(value)) {
44- return false;
45+ return null;
46 }
47
48 const summary = value as Record<string, unknown>;
49- return (
50- typeof summary.assistant_message_id === "string"
51- && typeof summary.block_count === "number"
52- && (summary.conversation_id == null || typeof summary.conversation_id === "string")
53- && typeof summary.duplicate_instruction_count === "number"
54- && isStringArray(summary.duplicate_tools)
55- && (summary.error_block_index == null || typeof summary.error_block_index === "number")
56- && (summary.error_message == null || typeof summary.error_message === "string")
57- && (summary.error_stage == null || typeof summary.error_stage === "string")
58- && isStringArray(summary.executed_tools)
59- && typeof summary.execution_count === "number"
60- && typeof summary.execution_failed_count === "number"
61- && typeof summary.execution_ok_count === "number"
62- && typeof summary.ingested_at === "number"
63- && typeof summary.instruction_count === "number"
64- && isStringArray(summary.instruction_tools)
65- && typeof summary.message_dedupe_key === "string"
66- && (summary.observed_at == null || typeof summary.observed_at === "number")
67- && typeof summary.platform === "string"
68- && summary.source === "browser.final_message"
69- && typeof summary.status === "string"
70- );
71+
72+ if (
73+ typeof summary.assistant_message_id !== "string"
74+ || typeof summary.block_count !== "number"
75+ || (summary.conversation_id != null && typeof summary.conversation_id !== "string")
76+ || typeof summary.duplicate_instruction_count !== "number"
77+ || !isStringArray(summary.duplicate_tools)
78+ || (summary.error_block_index != null && typeof summary.error_block_index !== "number")
79+ || (summary.error_message != null && typeof summary.error_message !== "string")
80+ || (summary.error_stage != null && typeof summary.error_stage !== "string")
81+ || !isStringArray(summary.executed_tools)
82+ || typeof summary.execution_count !== "number"
83+ || typeof summary.execution_failed_count !== "number"
84+ || typeof summary.execution_ok_count !== "number"
85+ || typeof summary.ingested_at !== "number"
86+ || typeof summary.instruction_count !== "number"
87+ || !isStringArray(summary.instruction_tools)
88+ || typeof summary.message_dedupe_key !== "string"
89+ || (summary.observed_at != null && typeof summary.observed_at !== "number")
90+ || typeof summary.platform !== "string"
91+ || summary.source !== "browser.final_message"
92+ || typeof summary.status !== "string"
93+ ) {
94+ return null;
95+ }
96+
97+ if (
98+ summary.parse_error_count != null
99+ && (typeof summary.parse_error_count !== "number" || !Number.isFinite(summary.parse_error_count))
100+ ) {
101+ return null;
102+ }
103+
104+ if (
105+ summary.parse_errors != null
106+ && (
107+ !Array.isArray(summary.parse_errors)
108+ || !summary.parse_errors.every((entry) => isBaaLiveInstructionParseErrorSummary(entry))
109+ )
110+ ) {
111+ return null;
112+ }
113+
114+ const parseErrors = Array.isArray(summary.parse_errors)
115+ ? (summary.parse_errors as BaaLiveInstructionParseErrorSummary[]).map((entry) => ({
116+ block_index: entry.block_index,
117+ message: entry.message,
118+ stage: entry.stage
119+ }))
120+ : [];
121+
122+ return {
123+ assistant_message_id: summary.assistant_message_id,
124+ block_count: summary.block_count,
125+ conversation_id: summary.conversation_id as string | null,
126+ duplicate_instruction_count: summary.duplicate_instruction_count,
127+ duplicate_tools: [...summary.duplicate_tools],
128+ error_block_index: (summary.error_block_index as number | null | undefined) ?? null,
129+ error_message: (summary.error_message as string | null | undefined) ?? null,
130+ error_stage: (summary.error_stage as string | null | undefined) ?? null,
131+ executed_tools: [...summary.executed_tools],
132+ execution_count: summary.execution_count,
133+ execution_failed_count: summary.execution_failed_count,
134+ execution_ok_count: summary.execution_ok_count,
135+ ingested_at: summary.ingested_at,
136+ instruction_count: summary.instruction_count,
137+ instruction_tools: [...summary.instruction_tools],
138+ message_dedupe_key: summary.message_dedupe_key,
139+ observed_at: (summary.observed_at as number | null | undefined) ?? null,
140+ parse_error_count:
141+ typeof summary.parse_error_count === "number"
142+ ? Math.max(0, Math.trunc(summary.parse_error_count))
143+ : parseErrors.length,
144+ parse_errors: parseErrors,
145+ platform: summary.platform,
146+ source: summary.source,
147+ status: summary.status as BaaLiveInstructionIngestSummary["status"]
148+ };
149 }
150
151 function parseSummary(summaryJson: string): BaaLiveInstructionIngestSummary | null {
152 try {
153 const parsed = JSON.parse(summaryJson) as unknown;
154- return isBaaLiveInstructionIngestSummary(parsed) ? cloneSummary(parsed) : null;
155+ const normalized = normalizeSummary(parsed);
156+ return normalized == null ? null : cloneSummary(normalized);
157 } catch {
158 return null;
159 }
1@@ -10,7 +10,8 @@ export type BaaInstructionProcessStatus =
2 | "denied_only"
3 | "duplicate_only"
4 | "executed"
5- | "no_instructions";
6+ | "no_instructions"
7+ | "parse_error_only";
8
9 export interface BaaExtractedBlock {
10 blockIndex: number;
11@@ -105,12 +106,21 @@ export interface BaaAssistantMessageInput extends BaaInstructionSourceMessage {
12 text: string;
13 }
14
15+export type BaaInstructionParseErrorStage = "normalize" | "parse";
16+
17+export interface BaaInstructionParseErrorRecord {
18+ blockIndex: number;
19+ message: string;
20+ stage: BaaInstructionParseErrorStage;
21+}
22+
23 export interface BaaInstructionProcessResult {
24 blocks: BaaExtractedBlock[];
25 denied: BaaInstructionDeniedResult[];
26 duplicates: BaaInstructionEnvelope[];
27 executions: BaaInstructionExecutionResult[];
28 instructions: BaaInstructionEnvelope[];
29+ parseErrors: BaaInstructionParseErrorRecord[];
30 status: BaaInstructionProcessStatus;
31 }
32
+19,
-6
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- 当前状态:`待开始`
6+- 当前状态:`已完成`
7 - 规模预估:`S`
8 - 依赖任务:无
9 - 建议执行者:`Codex`
10@@ -110,22 +110,35 @@
11
12 ### 开始执行
13
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-04-01 10:50:00 CST`
18 - 状态变更:`待开始` → `进行中`
19
20 ### 完成摘要
21
22-- 完成时间:
23+- 完成时间:`2026-04-01 11:09:33 CST`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+ - `apps/conductor-daemon/src/instructions/types.ts`
27+ - `apps/conductor-daemon/src/instructions/normalize.ts`
28+ - `apps/conductor-daemon/src/instructions/loop.ts`
29+ - `apps/conductor-daemon/src/instructions/ingest.ts`
30+ - `apps/conductor-daemon/src/instructions/store.ts`
31+ - `apps/conductor-daemon/src/index.test.js`
32+ - `tasks/T-S063.md`
33 - 核心实现思路:
34+ - `normalize` 改为逐 block `try/catch`,单个坏 block 只记入 `parseErrors`,不再中断后续合法 block。
35+ - `processResult` 与 live ingest summary 新增 parse error 摘要,保留整批失败时原有 `error_*` 语义。
36+ - 持久化 snapshot 读取兼容旧 journal,没有新字段时会自动回填默认值。
37 - 跑了哪些测试:
38+ - `pnpm -C /Users/george/code/baa-conductor-normalize-parse-error-isolation install --frozen-lockfile`
39+ - `pnpm -C /Users/george/code/baa-conductor-normalize-parse-error-isolation/apps/conductor-daemon typecheck`
40+ - `pnpm -C /Users/george/code/baa-conductor-normalize-parse-error-isolation/apps/conductor-daemon test`
41
42 ### 执行过程中遇到的问题
43
44-- 暂无
45+- 新建 worktree 后缺少 `node_modules`,先执行 `pnpm install --frozen-lockfile` 再继续 `typecheck` 和测试。
46
47 ### 剩余风险
48
49-- 暂无
50+- `/v1/browser` 当前仍沿用旧的 summary serializer,尚未把新增 parse error 摘要字段对外透出;本次任务范围内已保证 `processResult` 与 ingest summary 本身可拿到该信息。