mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user