From 2f3a614ea4c9684bfd7cf60b0b35669b421fef77 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 15:04:10 -0700 Subject: [PATCH] fix(orchestration): keep optional preamble sections out of Markdown headings The sub-dispatch and base-drift sections end with a bare rule directly under a paragraph, which Markdown parses as a setext H2, so the Chat UI rendered the section's last sentence as a heading. The unfenced sub-dispatch commands also lost their angle-bracket placeholders to the raw-HTML pass. Fence those commands like the main CLI block and put a blank line before each closing rule. --- .../coordinator-task-dispatch.ts | 2 +- .../runtime/orchestration/preamble.test.ts | 37 +++++++++++++++++-- src/main/runtime/orchestration/preamble.ts | 6 +++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/orchestration/coordinator-task-dispatch.ts b/src/main/runtime/orchestration/coordinator-task-dispatch.ts index 685552cc2e9..e058d9cdc98 100644 --- a/src/main/runtime/orchestration/coordinator-task-dispatch.ts +++ b/src/main/runtime/orchestration/coordinator-task-dispatch.ts @@ -132,7 +132,7 @@ export async function dispatchTaskToWorker(params: { let gateContext = '' if (gates.length > 0) { const latest = gates.at(-1)! - gateContext = `\n\n--- DECISION GATE RESOLVED ---\nQuestion: ${latest.question}\nResolution: ${latest.resolution}\n---\n` + gateContext = `\n\n--- DECISION GATE RESOLVED ---\nQuestion: ${latest.question}\nResolution: ${latest.resolution}\n\n---\n` } try { diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index 50dae26b077..57b8b35f266 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -31,6 +31,16 @@ function cliFence(result: string): string { return match?.[1] ?? '' } +function markdownBlocks(result: string) { + const tree = unified().use(remarkParse).parse(result) + return { + headings: tree.children.filter((node) => node.type === 'heading'), + codeBlocks: tree.children.filter((node) => node.type === 'code') + } +} + +const driftParams = { base: 'origin/main', behind: 3, recentSubjects: ['fix: a', 'feat: b'] } + describe('buildDispatchPreamble', () => { it('substitutes template variables', () => { const result = buildDispatchPreamble(baseParams()) @@ -73,15 +83,36 @@ describe('buildDispatchPreamble', () => { it('fences shell comments so Markdown does not promote them to headings', () => { const result = buildDispatchPreamble(baseParams()) - const tree = unified().use(remarkParse).parse(result) - const headings = tree.children.filter((node) => node.type === 'heading') - const codeBlocks = tree.children.filter((node) => node.type === 'code') + const { headings, codeBlocks } = markdownBlocks(result) expect(headings).toHaveLength(0) expect(codeBlocks).toHaveLength(1) expect(codeBlocks[0]).toMatchObject({ lang: 'sh', value: cliFence(result) }) }) + // Why: a `---` rule directly under a paragraph is a setext H2, so the optional + // sections' closing rules must not turn their last sentence into a heading. + it('renders no Markdown headings when the sub-dispatch and drift sections are present', () => { + const result = buildDispatchPreamble( + baseParams({ canDispatchSubWorkers: true, baseDrift: driftParams }) + ) + const { headings, codeBlocks } = markdownBlocks(result) + + expect(headings).toHaveLength(0) + expect(codeBlocks).toHaveLength(2) + expect(codeBlocks[1]).toMatchObject({ lang: 'sh' }) + expect(codeBlocks[1].value).toContain('orchestration worker-start --task ') + expect(result).toContain('able to dispatch further.\n\n---') + expect(result).toContain('before starting.\n\n---') + }) + + it('sub-dispatch fence passes bash -n', { timeout: 15_000 }, () => { + const result = buildDispatchPreamble(baseParams({ canDispatchSubWorkers: true })) + const { codeBlocks } = markdownBlocks(result) + const check = spawnSync('bash', ['-n'], { input: codeBlocks[1].value, encoding: 'utf8' }) + expect(check.status).toBe(0) + }) + it('includes heartbeat CLI block with taskId and dispatchId and 5-minute cadence', () => { const result = buildDispatchPreamble(baseParams()) expect(result).toContain('--type heartbeat') diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index f43bcb0733b..d4519f154b9 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -196,6 +196,8 @@ work under the new Dispatch; ignore stale follow-ups from the settled task.` // Why the whole section is omitted rather than softened when nesting is off: a // worker told it "usually cannot" delegate still tries, then reports the refusal // as a blocker. +// Why fenced + blank line before the closing `---`: unfenced `` are stripped as raw +// HTML by the Chat UI, and a rule directly under a paragraph is a setext H2 (giant last sentence). function buildSubDispatchSection(cli: string): string { return ` @@ -203,13 +205,16 @@ function buildSubDispatchSection(cli: string): string { You may dispatch sub-workers for this task. Bind your own Run first, then create and start each one: +\`\`\`sh ${cli} orchestration run-create --objective "" --json ${cli} orchestration task-create --spec "" --json ${cli} orchestration worker-start --task --worktree current --agent --json +\`\`\` You own those sub-workers: wait for their worker_done, and do not report your own until they have settled. Nesting is capped, so a sub-worker of yours may not be able to dispatch further. + ---` } @@ -224,5 +229,6 @@ ${subjects} If any look relevant to your task, either pull them in (\`git pull --rebase ${drift.base}\` or equivalent) or escalate to the coordinator before starting. + ---` }