fix(zcode): wait for composer before first worker dispatch (#23374)

* fix: wait for ZCode composer before first worker dispatch

* fix: preserve readiness across renderer terminal adoption
This commit is contained in:
Alex-wangyang
2026-09-27 22:02:19 -07:00
committed by GitHub
parent d33541c335
commit 47b8408e28
6 changed files with 173 additions and 8 deletions
@@ -27,7 +27,8 @@ import type {
import { recordCreatedWorktreeLineage as recordCreatedWorktreeLineageState } from './runtime-worktree-lineage-recording'
import {
pasteWorktreeStartupDraftWhenReady,
sendWorktreeStartupFollowupWhenReady
sendWorktreeStartupFollowupWhenReady,
waitForWorktreeStartupDraft
} from './runtime-worktree-startup-readiness'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { provisionWorktreeTerminals } from './runtime-worktree-terminal-provisioning'
@@ -203,6 +204,29 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM
pasteWorktreeStartupDraftWhenReady(this.getWorktreeStartupReadinessHost(), handle, draft)
}
/** Only for a newly launched worker, before its first dispatch input. */
async waitForFreshWorkerComposer(
handle: string,
agent: TuiAgent,
timeoutMs: number
): Promise<void> {
const initialPtyId =
this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId
const ptyId = await waitForWorktreeStartupDraft(
{ ...this.getWorktreeStartupReadinessHost(), getPtyId: () => initialPtyId },
handle,
agent,
{ timeoutMs, requireComposerMarker: true }
)
if (!ptyId) {
throw new Error('timeout')
}
this.assertLiveTerminalHandleTargetsPty(handle, ptyId)
if (!this.ptysById.get(ptyId)?.connected) {
throw new Error('terminal_handle_stale')
}
}
protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void {
sendWorktreeStartupFollowupWhenReady(this.getWorktreeStartupReadinessHost(), handle, followup)
}
@@ -188,10 +188,17 @@ export async function startLocalWorker(args: {
effects,
timeoutMs: params.timeoutMs ?? 60_000
})
: await runtime.waitForTerminal(terminalHandle, {
condition: 'tui-idle',
timeoutMs: params.timeoutMs ?? 60_000
})
: // ZCode emits SessionStart only after input; its first dispatch must wait for the composer.
agent === 'zcode' && !params.terminal
? await runtime.waitForFreshWorkerComposer(
terminalHandle,
agent,
params.timeoutMs ?? 60_000
)
: await runtime.waitForTerminal(terminalHandle, {
condition: 'tui-idle',
timeoutMs: params.timeoutMs ?? 60_000
})
if (wait) {
persistWorkerSetupWaitOutcome({ ...setupStage, wait })
if (!wait.satisfied) {
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support'
describe('ZCode first dispatch readiness', () => {
const h = createOrchestrationWorkerReleaseHarness()
afterEach(() => h.cleanup())
it('waits for the new composer before delivering exactly one dispatch', async () => {
h.setup()
const gate = h.deferred<void>()
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise)
const pending = h.startWorker({ agent: 'zcode' })
await vi.waitFor(() =>
expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith(
'term_worker',
'zcode',
60_000
)
)
expect(h.runtime.waitForTerminal).not.toHaveBeenCalled()
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
gate.resolve()
await pending
expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce()
})
it('keeps reused terminals on the normal idle wait', async () => {
h.setup()
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer')
await h.startWorker({ terminal: 'term_worker' })
expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled()
expect(h.runtime.waitForTerminal).toHaveBeenCalledWith(
'term_worker',
expect.objectContaining({ condition: 'tui-idle' })
)
})
it('never delivers a task after a startup timeout', async () => {
h.setup()
vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout'))
await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start')
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,71 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
waitForWorktreeStartupDraft,
type WorktreeStartupReadinessHost
} from './runtime-worktree-startup-readiness'
describe('fresh worker composer readiness', () => {
afterEach(() => vi.useRealTimers())
function fixture(replay?: string) {
let listener = (_data: string): void => {}
const unsubscribe = vi.fn()
const host: WorktreeStartupReadinessHost = {
getPtyId: () => 'pty-1',
getForegroundProcess: async () => 'zcode',
subscribeToData: (_ptyId, onData) => {
listener = onData
return unsubscribe
},
readRecentOutput: () => replay,
write: vi.fn()
}
return { host, emit: (data: string) => listener(data), unsubscribe }
}
it('accepts the captured composer while the banner continues repainting', async () => {
vi.useFakeTimers()
const h = fixture()
const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', {
timeoutMs: 45_000,
requireComposerMarker: true
})
const data = readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8')
for (let offset = 0; offset < data.length; offset += 4096) {
h.emit(data.slice(offset, offset + 4096))
}
await expect(pending).resolves.toBe('pty-1')
expect(h.unsubscribe).toHaveBeenCalledOnce()
expect(h.host.write).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('cleans up the deadline when the composer was already captured', async () => {
vi.useFakeTimers()
const h = fixture('\x1b[?1049h╭')
await expect(
waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', {
timeoutMs: 45_000,
requireComposerMarker: true
})
).resolves.toBe('pty-1')
expect(h.unsubscribe).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it('does not accept shell decoration or a square startup dialog', async () => {
vi.useFakeTimers()
const h = fixture('╭ shell\n\x1b[?1049h\x1b[?2004h┌ Sign in ┐')
const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', {
timeoutMs: 45_000,
requireComposerMarker: true
})
await vi.advanceTimersByTimeAsync(44_999)
expect(h.unsubscribe).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await expect(pending).resolves.toBeNull()
expect(h.unsubscribe).toHaveBeenCalledOnce()
})
})
@@ -89,7 +89,8 @@ export async function waitForWorktreeStartupFollowup(
export function waitForWorktreeStartupDraft(
host: WorktreeStartupReadinessHost,
handle: string,
agent: TuiAgent
agent: TuiAgent,
options: { timeoutMs?: number; requireComposerMarker?: boolean } = {}
): Promise<string | null> {
const ptyId = host.getPtyId(handle)
if (!ptyId) {
@@ -118,11 +119,14 @@ export function waitForWorktreeStartupDraft(
resolve(value)
}
const observe = (data: string): void => {
if (settled) {
return
}
const result = scanner.observe(data)
if (result.ready) {
return finish(ptyId)
}
if (result.armQuietTimer) {
if (result.armQuietTimer && !options.requireComposerMarker) {
if (quietTimer) {
clearTimeout(quietTimer)
}
@@ -130,10 +134,13 @@ export function waitForWorktreeStartupDraft(
}
}
unsubscribe = host.subscribeToData(ptyId, observe)
hardTimer = setTimeout(
() => finish(null),
options.timeoutMs ?? resolveDraftPasteReadyTimeoutMs(agent)
)
const replay = host.readRecentOutput(ptyId)
if (replay) {
observe(replay)
}
hardTimer = setTimeout(() => finish(null), resolveDraftPasteReadyTimeoutMs(agent))
})
}
@@ -21,6 +21,18 @@ function readTranscript(): string {
}
describe('ZCode readiness from captured terminal bytes', () => {
it('accepts a fresh composer after the renderer adopts the terminal handle', async () => {
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'worker-zcode',
foregroundProcess: 'zcode',
launchAgent: 'zcode',
data: '\x1b[?1049h╭'
})
await expect(
runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)
).resolves.toBeUndefined()
})
it('never emits an OSC title, so no title lane can settle its wait', () => {
const data = readTranscript()
expect(data).toContain(String.fromCharCode(27))