fix(orchestration): wait for Claude composer render (#14342)

This commit is contained in:
Jinwoo Hong
2026-08-13 15:04:32 -07:00
committed by GitHub
parent a12f8be144
commit e06cdbb4ee
3 changed files with 269 additions and 9 deletions
+26 -5
View File
@@ -8276,10 +8276,10 @@
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract and a live dev-runtime CLI repro. SSH, daemon, remote-runtime, Linux, and Windows remain provider/platform gaps; the product path stays provider-owned and does not add local filesystem or process assumptions.",
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, a live dev-runtime CLI repro, and a Haiku composer trace. The Claude render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca/issues/7226"],
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and send Enter only after the paste frame completes.",
"oracle": "Runtime tests assert the exact PTY write sequence and failure cleanup; orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness dispatches a 32KB task to a fake Codex-like TUI and requires marker present, bracketed paste present, zero unframed line breaks, and submit observed.",
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude must emit a post-paste composer marker and then settle, or reach the bounded fallback first.",
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, and a Claude-shaped multi-frame render where the legacy 500 ms delay and first show-cursor marker are both premature. The candidate waits for post-marker output quiescence before one submit, resets that window on later frames, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
"node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
@@ -8305,6 +8305,9 @@
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"runtime writes bracketed paste before a delayed submit",
"Claude ignores unrelated and split pre-marker output, then waits for post-marker render quiescence before one submit",
"a silent Claude composer reaches one bounded fallback submit",
"a marked Claude composer with continuous render output reaches one bounded fallback submit",
"large prompt frames are chunked and reconstructed before submit",
"partial prompt write failure closes the paste frame and does not submit"
]
@@ -8334,6 +8337,24 @@
}
],
"evidenceRuns": [
{
"date": "2026-08-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
"result": "passed",
"durationSeconds": 13.3,
"summary": "4 files and 1,283 tests passed. The hardened multi-frame oracle failed on the first-marker candidate because it submitted at 751 ms during an intermediate Claude frame; the quiescence candidate waited through the final 1,000 ms frame and submitted once at 2,500 ms. Continuous render output remained bounded to one fallback submit at 8 seconds. An isolated Claude Code 2.1.231 Haiku probe saw the first marker at 400 ms, continued output through 1,500 ms, sent one Enter at 3,000 ms after 1.5 seconds quiet, and created the expected marker; no Fable or Opus probe was used."
},
{
"date": "2026-08-13",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
"result": "passed",
"durationSeconds": 16.9,
"summary": "4 files and 1,282 tests passed. Unmodified main wrote Enter at 500 ms before the deterministic Claude composer rendered at 750 ms; the candidate waited for the split show-cursor marker and wrote one Enter. A live Claude Code 2.1.231 Haiku trace rendered the pasted marker and show-cursor in one 523-byte frame without submitting a model request."
},
{
"date": "2026-07-07",
"runner": "local",
@@ -8363,11 +8384,11 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The live harness reproduced the unsafe raw multiline contract before the fix and passes after the fix; intentional-break evidence is local only and not yet in CI."
"evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
},
"performanceBudget": {
"required": true,
"evidence": "Agent prompt dispatch remains O(prompt bytes), uses the existing 16KB terminal input chunking and one existing 500ms submit delay, and adds no polling, provider listing, subprocess churn, hidden-pane wakeups, or renderer work."
"evidence": "Agent prompt dispatch remains O(prompt bytes) with existing 16KB chunking. Claude adds one PTY-local output listener until 1.5s of post-marker output quiescence or an 8s fallback; each later frame resets one timer. Other agents retain the platform delay. No polling, provider listing, subprocess churn, hidden-pane wakeups, renderer work, or wire traffic is added."
},
"promotionCriteria": [
"Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.",
+145 -2
View File
@@ -16394,6 +16394,145 @@ describe('OrcaRuntimeService', () => {
}
})
it('waits for Claude output to settle after its first render marker before one submit', async () => {
vi.useFakeTimers()
try {
const writes: string[] = []
let composerReady = false
let prematureEnters = 0
let submissions = 0
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
setTimeout(() => {
runtime.onPtyData('pty-bg', 'partial redraw without cursor', Date.now())
}, 650)
setTimeout(() => {
runtime.onPtyData('pty-bg', '\x1b[?2', Date.now())
}, 750)
setTimeout(() => {
runtime.onPtyData('pty-bg', '5h intermediate frame', Date.now())
}, 751)
setTimeout(() => {
runtime.onPtyData('pty-bg', 'continued composer render', Date.now())
}, 900)
setTimeout(() => {
composerReady = true
runtime.onPtyData('pty-bg', 'final composer frame', Date.now())
}, 1_000)
}
if (data === '\r') {
if (composerReady) {
submissions += 1
} else {
prematureEnters += 1
}
}
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
launchAgent: 'claude'
})
const assertAuthority = vi.fn()
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change', {
beforeWrite: assertAuthority
})
await vi.advanceTimersByTimeAsync(500)
expect(writes).not.toContain('\r')
await vi.advanceTimersByTimeAsync(150)
expect(writes).not.toContain('\r')
await vi.advanceTimersByTimeAsync(101)
expect(writes).not.toContain('\r')
await vi.advanceTimersByTimeAsync(1_748)
expect(writes).not.toContain('\r')
await vi.advanceTimersByTimeAsync(1)
await sendPromise
expect(prematureEnters).toBe(0)
expect(submissions).toBe(1)
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
expect(assertAuthority).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('submits a silent Claude composer once after the bounded render fallback', async () => {
vi.useFakeTimers()
try {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
launchAgent: 'claude'
})
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
await vi.advanceTimersByTimeAsync(7_999)
expect(writes).not.toContain('\r')
await vi.advanceTimersByTimeAsync(1)
await sendPromise
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
} finally {
vi.useRealTimers()
}
})
it('bounds a Claude render that never settles to one fallback submit', async () => {
vi.useFakeTimers()
try {
const writes: string[] = []
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
write: (_ptyId, data) => {
writes.push(data)
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
setTimeout(() => runtime.onPtyData('pty-bg', '\x1b[?25h', Date.now()), 100)
for (const delay of [1_000, 2_000, 3_000, 4_000, 5_000, 6_000, 7_000]) {
setTimeout(
() => runtime.onPtyData('pty-bg', `render frame ${delay}`, Date.now()),
delay
)
}
}
return true
},
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
launchAgent: 'claude'
})
const sendPromise = runtime.sendTerminalAgentPrompt(handle, 'review this change')
await vi.advanceTimersByTimeAsync(7_999)
expect(writes).not.toContain('\r')
await vi.advanceTimersByTimeAsync(1)
await sendPromise
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
} finally {
vi.useRealTimers()
}
})
it('chunks large agent prompt paste frames before delayed submit', async () => {
vi.useFakeTimers()
try {
@@ -16408,7 +16547,9 @@ describe('OrcaRuntimeService', () => {
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
launchAgent: 'claude'
})
const prompt = `${'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES)}\ntail`
const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt)
@@ -16445,7 +16586,9 @@ describe('OrcaRuntimeService', () => {
kill: () => true,
getForegroundProcess: async () => null
})
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`)
const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
launchAgent: 'claude'
})
const prompt = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1)
const sendPromise = runtime.sendTerminalAgentPrompt(handle, prompt)
+98 -2
View File
@@ -1831,6 +1831,10 @@ const BRACKETED_PASTE_BEGIN = '\x1b[200~'
const BRACKETED_PASTE_END = '\x1b[201~'
const BRACKETED_PASTE_QUIET_MS = 1500
const DRAFT_PASTE_READY_TIMEOUT_MS = 8000
const CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS = 8000
const CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS = 1500
// Why: Claude emits show-cursor while rendering its composer; output must settle afterward.
const CLAUDE_AGENT_PROMPT_RENDER_MARKER = '\x1b[?25h'
const MOBILE_TERMINAL_SURFACE_TIMEOUT_MS = 10_000
// Why: the split already failed; the caller waits on this teardown only to learn whether the
// fallback kill is needed, so keep it short — an unreachable host must not stall the rejection.
@@ -17438,19 +17442,24 @@ export class OrcaRuntimeService {
suffixFailureError?: string
} = {}
): Promise<void> {
const renderGate = this.createClaudeAgentPromptRenderGate(ptyId)
let wrotePasteBytes = false
let completedPaste = false
try {
const chunks = iterateTerminalInputChunks(pastePayload)
let chunk = chunks.next()
while (!chunk.done) {
const nextChunk = chunks.next()
await options.beforeWrite?.(ptyId)
if (nextChunk.done) {
renderGate?.arm()
}
const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false
if (!wrote) {
throw new Error('terminal_not_writable')
}
wrotePasteBytes = true
chunk = chunks.next()
chunk = nextChunk
if (!chunk.done) {
await new Promise((resolve) => setTimeout(resolve, 0))
}
@@ -17460,10 +17469,16 @@ export class OrcaRuntimeService {
if (wrotePasteBytes && !completedPaste) {
this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END)
}
renderGate?.dispose()
throw error
}
await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_SUBMIT_DELAY_MS))
if (renderGate) {
await renderGate.wait()
renderGate.dispose()
} else {
await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_SUBMIT_DELAY_MS))
}
try {
await options.beforeWrite?.(ptyId)
} catch (error) {
@@ -17478,6 +17493,87 @@ export class OrcaRuntimeService {
}
}
private createClaudeAgentPromptRenderGate(ptyId: string): {
arm: () => void
wait: () => Promise<void>
dispose: () => void
} | null {
const pty = this.ptysById.get(ptyId)
if ((pty?.launchAgent ?? pty?.foregroundAgent) !== 'claude') {
return null
}
let armed = false
let observedMarker = false
let settled = false
let markerCarry = ''
let quietTimer: NodeJS.Timeout | null = null
let hardTimer: NodeJS.Timeout | null = null
let resolveRender!: () => void
const rendered = new Promise<void>((resolve) => {
resolveRender = resolve
})
const finish = (): void => {
if (settled) {
return
}
settled = true
if (quietTimer) {
clearTimeout(quietTimer)
quietTimer = null
}
if (hardTimer) {
clearTimeout(hardTimer)
hardTimer = null
}
resolveRender()
}
const armQuietTimer = (): void => {
if (quietTimer) {
clearTimeout(quietTimer)
}
quietTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_QUIET_MS)
}
const unsubscribe = this.subscribeToTerminalData(ptyId, (data) => {
if (!armed || settled) {
return
}
if (!observedMarker) {
const combined = markerCarry + data
markerCarry = combined.slice(-(CLAUDE_AGENT_PROMPT_RENDER_MARKER.length - 1))
if (!combined.includes(CLAUDE_AGENT_PROMPT_RENDER_MARKER)) {
return
}
observedMarker = true
}
armQuietTimer()
})
return {
arm: () => {
armed = true
markerCarry = ''
},
wait: async () => {
if (settled) {
return
}
hardTimer = setTimeout(finish, CLAUDE_AGENT_PROMPT_RENDER_TIMEOUT_MS)
await rendered
},
dispose: () => {
unsubscribe()
if (quietTimer) {
clearTimeout(quietTimer)
quietTimer = null
}
if (hardTimer) {
clearTimeout(hardTimer)
hardTimer = null
}
}
}
}
async waitForTerminal(
handle: string,
options?: {