Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/main/runtime/orca-runtime-activate-managed-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike the replaced waitForTerminal('tui-idle'), waitForWorktreeStartupDraft has no exit/disconnect subscription — it settles only on the composer marker or the hard timer. A zcode worker that crashes or loses its PTY before painting ╭ therefore stalls for the full worker-start timeout (60s default) before failing with a generic timeout, where the old wait rejected immediately with terminal_exited.

Technical details
# Detect terminal exit while waiting for the composer

## Affected sites
- `src/main/runtime/orca-runtime-activate-managed-worktree.ts:215` — delegates to the composer wait with no exit signal
- `src/main/runtime/runtime-worktree-startup-readiness.ts:137` — the hard timer is the only failure backstop under `requireComposerMarker`
- `src/main/runtime/orca-runtime-resolve-exit-waiters.ts:29` — the `terminal_exited` rejection the old `tui-idle` wait relied on

## Required outcome
- A zcode launch whose PTY exits before the composer appears should fail promptly, not after `timeoutMs`.

## Suggested approach
- Race the composer wait against a pty-exit/disconnect signal (or re-check liveness on the existing data stream) and settle `null`/throw when the terminal dies.

## Open question
- If the agent process exits but the shell PTY survives, the old wait also ran to timeout — confirm the regression is limited to the PTY-exit/disconnect case and decide whether the latency is an accepted trade-off.

{ ...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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The zcode branch awaits waitForFreshWorkerComposer, whose return type is Promise<void>, so wait is undefined and the if (wait) block below (line 202) is skipped. On a --worktree new-child zcode start where the repo policy is wait-for-setup, persistWorkerSetupWaitOutcome / applyWaitForSetupOutcome never run: the setup receipt stays running while the dispatch is reported ready, no setup_settled stage is recorded, and a setup that ran and failed surfaces as an agent_readiness timeout rather than a setup failure.

Technical details
# Settle the setup wait outcome on the zcode branch

## Affected sites
- `src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts:193` — branch result is `void`, so `if (wait)` at `:202` never runs
- `src/main/runtime/orca-runtime-activate-managed-worktree.ts:208` — `waitForFreshWorkerComposer(...): Promise<void>`
- `src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts:53` — `persistWorkerSetupWaitOutcome` → `applyWaitForSetupOutcome`
- `src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts:130` — the only local path that flips a `wait-for-setup` receipt `running` → `succeeded`

## Required outcome
- A successful composer wait on a `wait-for-setup` worktree must settle the setup receipt (`state: 'succeeded'`) and record the `setup_settled` stage before task input is delivered, matching the invariant asserted for the non-zcode path at `workers-new-worktree.test.ts:369`.

## Suggested approach
- Return a satisfied wait result (e.g. `{ satisfied: true, status: 'running' }`) from `waitForFreshWorkerComposer` so the existing `if (wait)` block applies, or call `persistWorkerSetupWaitOutcome({ ...setupStage, wait: { satisfied: true, status: 'running' } })` on the zcode success path.

## Open question
- `monitorWorkerSetup` bails for `wait-for-setup`, so nothing reconciles the receipt later; confirm the receipt is intended to stay `running` if this branch is left as-is.

terminalHandle,
agent,
params.timeoutMs ?? 60_000
)
Comment on lines +193 to +197

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,140p' src/main/runtime/rpc/methods/orchestration/worker/worker-setup-gate.ts
sed -n '140,235p' src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts
rg -n 'composer_ready|type TerminalWait|interface TerminalWait|wait-for-setup|setup_settled' src/main/runtime/rpc/methods/orchestration/worker src/shared

Repository: stablyai/orca

Length of output: 9125


🏁 Script executed:

sed -n '1,190p' src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts
sed -n '1,120p' src/main/runtime/rpc/methods/orchestration/worker/worker-start-structured-setup-gate.ts
sed -n '180,235p' src/main/runtime/orca-runtime-activate-managed-worktree.ts
rg -n -C 5 'waitForFreshWorkerComposer|composer_ready|applyWaitForSetupOutcome|WaitResult|TerminalWait|status:' src/main/runtime src/shared
sed -n '340,470p' src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts

Repository: stablyai/orca

Length of output: 45658


🏁 Script executed:

rg -n -C 8 'waitForWorktreeStartupDraft|setup runner|agent-first|startupPolicy|agent === .zcode.|requireComposerMarker|SessionStart' src/main/runtime/rpc/methods/orchestration/worker src/main/runtime src/shared
fd -i 'worker-worktree-creation' src
rg -n -C 10 'zcode|composer|wait-for-setup' src/main/runtime/rpc/methods/orchestration/worker/*test.ts src/main/runtime/*test.ts

Repository: stablyai/orca

Length of output: 45665


Record ZCode composer readiness as the setup wait outcome.

For a fresh non-structured ZCode worker, waitForFreshWorkerComposer returns void. The if (wait) guard therefore skips persistWorkerSetupWaitOutcome, leaving a running wait-for-setup receipt and stage unsettled. PTY startup runs behind the setup runner, so composer readiness is sufficient for this setup gate. Return a satisfied wait result with status: 'composer_ready'.

Suggested fix
           await runtime.waitForFreshWorkerComposer(
             terminalHandle,
             agent,
             params.timeoutMs ?? 60_000
-          )
+          ).then(() => ({
+            satisfied: true,
+            status: 'composer_ready'
+          }))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
? await runtime.waitForFreshWorkerComposer(
terminalHandle,
agent,
params.timeoutMs ?? 60_000
)
? await runtime.waitForFreshWorkerComposer(
terminalHandle,
agent,
params.timeoutMs ?? 60_000
).then(() => ({
satisfied: true,
status: 'composer_ready'
}))

: await runtime.waitForTerminal(terminalHandle, {
condition: 'tui-idle',
timeoutMs: params.timeoutMs ?? 60_000
})
if (wait) {
persistWorkerSetupWaitOutcome({ ...setupStage, wait })
if (!wait.satisfied) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
71 changes: 71 additions & 0 deletions src/main/runtime/runtime-worktree-startup-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
13 changes: 10 additions & 3 deletions src/main/runtime/runtime-worktree-startup-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -118,22 +119,28 @@ 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)
}
quietTimer = setTimeout(() => finish(ptyId), BRACKETED_PASTE_QUIET_MS)
}
}
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))
})
}
12 changes: 12 additions & 0 deletions src/main/runtime/zcode-readiness-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading