From 47b8408e289926cfe4e7ae40eefce29aaa2b32d1 Mon Sep 17 00:00:00 2001 From: Alex-wangyang <155510959+Alex-wangyang@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:02:19 +0800 Subject: [PATCH] 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 --- .../orca-runtime-activate-managed-worktree.ts | 26 ++++++- .../worker/local-worker-start.ts | 15 ++-- .../worker/zcode-worker-readiness.test.ts | 44 ++++++++++++ ...runtime-worktree-startup-readiness.test.ts | 71 +++++++++++++++++++ .../runtime-worktree-startup-readiness.ts | 13 +++- .../zcode-readiness-transcript.test.ts | 12 ++++ 6 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts create mode 100644 src/main/runtime/runtime-worktree-startup-readiness.test.ts diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 6b94e5d0ea2..48d81fc79cc 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -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 { + 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) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index d32232f8447..d6b265c41a9 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -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) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts new file mode 100644 index 00000000000..30d32c2ede8 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts @@ -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() + 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() + }) +}) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.test.ts b/src/main/runtime/runtime-worktree-startup-readiness.test.ts new file mode 100644 index 00000000000..e96a072f7ed --- /dev/null +++ b/src/main/runtime/runtime-worktree-startup-readiness.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 77b4e1315db..7208e0d3bde 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -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 { 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)) }) } diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts index 2319915ab68..bd9909f89de 100644 --- a/src/main/runtime/zcode-readiness-transcript.test.ts +++ b/src/main/runtime/zcode-readiness-transcript.test.ts @@ -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))