baa-conductor

git clone 

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
M apps/conductor-daemon/src/index.test.js
+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;
M apps/conductor-daemon/src/instructions/ingest.ts
+47, -3
  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   }
M apps/conductor-daemon/src/instructions/loop.ts
+72, -13
  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;
M apps/conductor-daemon/src/instructions/normalize.ts
+11, -5
 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 
M apps/conductor-daemon/src/instructions/store.ts
+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   }
M apps/conductor-daemon/src/instructions/types.ts
+11, -1
 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 
M tasks/T-S063.md
+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 本身可拿到该信息。