From f62c583474007ae476ff1397b9c62e436db342a1 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 22 Sep 2026 20:13:21 -0700 Subject: [PATCH 1/3] fix(runtime): answer startup terminal queries for background-created terminals A runtime-created terminal (orchestration worker-start, `orca terminal create`) has no renderer pane until the user opens its tab. Main only answers terminal queries for PTYs the renderer marked hidden, so on a fresh app sitting on the landing screen nothing answered the agent's startup cursor-position query. Muse waits ~2s per unanswered CPR and then exits 0 with no output, which surfaced as `agent_readiness: timeout`. Background runtime spawns now carry initiallyHidden, mirroring the renderer's hidden-at-spawn path: fresh daemon sessions are marked before byte zero, the committed id is marked and paced as backgrounded, and the mark is released on failure, reattach, or adoption. A pane that later mounts visible unmarks and restores from the model snapshot as before. --- .../pty-runtime-hidden-at-spawn-mark.test.ts | 187 ++++++++++++++++++ src/main/ipc/pty/register-handlers.ts | 5 +- src/main/ipc/pty/runtime/controller-deps.ts | 2 + src/main/ipc/pty/runtime/spawn-execute.ts | 4 + .../ipc/pty/runtime/spawn-hidden-delivery.ts | 60 ++++++ src/main/ipc/pty/runtime/spawn-state.ts | 6 +- src/main/ipc/pty/runtime/spawn.ts | 9 +- ...rminal-create-host-session-binding.test.ts | 8 + ...ca-runtime-create-terminal-dependencies.ts | 7 + .../runtime/orca-runtime-create-terminal.ts | 2 +- .../runtime-pty-controller-contract.ts | 2 + 11 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts create mode 100644 src/main/ipc/pty/runtime/spawn-hidden-delivery.ts diff --git a/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts b/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts new file mode 100644 index 000000000000..e26e0b244cc9 --- /dev/null +++ b/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, vi } from 'vitest' +import { makeDeferred } from './pty-ipc-test-constants' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { isHiddenRendererPty } from './pty-hidden-delivery-gate' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { registerPtyHandlers } from './pty' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +type RuntimeSpawnController = { + spawn: (args: { + cols: number + rows: number + sessionId?: string + initiallyHidden?: boolean + }) => Promise<{ id: string }> +} + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test doubles implement only the members these spawn paths read. +const testDouble = (value: unknown): T => value as T + +describe('runtime-controller spawn: hidden until a renderer view mounts', () => { + const { mainWindow, installObservableDaemonTestProvider } = setupPtyIpcSuite() + + function createRuntimeMock() { + return { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + registerPreAllocatedHandleForPty: vi.fn() + } + } + + function installController(runtime: { setPtyController: ReturnType }) { + registerPtyHandlers(testDouble(mainWindow), testDouble(runtime)) + return testDouble(runtime.setPtyController.mock.calls[0]?.[0]) + } + + it('marks a fresh daemon session hidden before spawn resolves', async () => { + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const spawnGate = makeDeferred() + let mintedSessionId: string | undefined + daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => { + mintedSessionId = options.sessionId + await spawnGate.promise + return { id: options.sessionId ?? 'daemon-pty' } + }) + const controller = installController(runtime) + + const spawnPromise = controller.spawn({ cols: 80, rows: 24, initiallyHidden: true }) + await vi.waitFor(() => expect(mintedSessionId).toBeDefined()) + // Byte zero (Muse's startup queries) must already be main's to answer. + expect(isHiddenRendererPty(mintedSessionId!)).toBe(true) + + spawnGate.resolve() + const result = await spawnPromise + expect(isHiddenRendererPty(result.id)).toBe(true) + }) + + it('clears the pre-spawn mark when the runtime spawn fails', async () => { + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + let mintedSessionId: string | undefined + daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => { + mintedSessionId = options.sessionId + throw new Error('spawn exploded') + }) + const controller = installController(runtime) + + await expect(controller.spawn({ cols: 80, rows: 24, initiallyHidden: true })).rejects.toThrow( + 'spawn exploded' + ) + expect(mintedSessionId).toBeDefined() + expect(isHiddenRendererPty(mintedSessionId!)).toBe(false) + }) + + it('does not mark a reattach, whose session may already back a visible pane', async () => { + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => ({ + id: options.sessionId ?? 'daemon-pty', + isReattach: true + })) + const controller = installController(runtime) + + const result = await controller.spawn({ + cols: 80, + rows: 24, + sessionId: 'existing-session', + initiallyHidden: true + }) + expect(isHiddenRendererPty(result.id)).toBe(false) + }) + + it('leaves spawns without the flag delivering to the renderer', async () => { + const runtime = createRuntimeMock() + installObservableDaemonTestProvider() + const controller = installController(runtime) + + const result = await controller.spawn({ cols: 80, rows: 24 }) + expect(isHiddenRendererPty(result.id)).toBe(false) + }) + + it('answers a startup cursor-position query from the model with no renderer view', async () => { + const daemon = installObservableDaemonTestProvider() + const runtime = new OrcaRuntimeService( + testDouble({ + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }), + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true + }) + }) + ) + const setPtyController = vi.spyOn(runtime, 'setPtyController') + registerPtyHandlers(testDouble(mainWindow), runtime) + const controller = testDouble(setPtyController.mock.calls[0]?.[0]) + + const result = await controller.spawn({ cols: 80, rows: 24, initiallyHidden: true }) + daemon.emitData(result.id, '\x1b[6n') + await runtime.serializeMainTerminalBuffer(result.id) + + expect(daemon.write).toHaveBeenCalledWith(result.id, '\x1b[1;1R') + }) +}) diff --git a/src/main/ipc/pty/register-handlers.ts b/src/main/ipc/pty/register-handlers.ts index 9a6230ad82b1..64b999051f46 100644 --- a/src/main/ipc/pty/register-handlers.ts +++ b/src/main/ipc/pty/register-handlers.ts @@ -232,7 +232,10 @@ export function registerPtyHandlers( trustedTerminalHandleEnv: session.trustedTerminalHandleEnv, retiredRejectedPtyIds: session.retiredRejectedPtyIds, reversibleStopOwnersByPtyId: session.reversibleStopOwnersByPtyId, - mainWindow + mainWindow, + transitionSpawnHiddenRendererPtyDeliveryState: + session.transitionSpawnHiddenRendererPtyDeliveryState, + syncPtyBackgroundedDelivery: session.syncPtyBackgroundedDelivery }) installPtySnapshotIpcHandlers({ runtime, pendingData: session.pendingData }) diff --git a/src/main/ipc/pty/runtime/controller-deps.ts b/src/main/ipc/pty/runtime/controller-deps.ts index c6231e5ef0b6..db55b637e57a 100644 --- a/src/main/ipc/pty/runtime/controller-deps.ts +++ b/src/main/ipc/pty/runtime/controller-deps.ts @@ -84,6 +84,8 @@ export type PtyRuntimeControllerDeps = { retiredRejectedPtyIds: Map reversibleStopOwnersByPtyId: Map mainWindow: BrowserWindow + transitionSpawnHiddenRendererPtyDeliveryState?: (id: string, hidden: boolean) => void + syncPtyBackgroundedDelivery?: (id: string, caller: string) => void } export type { StablePaneOwner } diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 05a9ca82d15b..8228175a2cf1 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -17,6 +17,7 @@ import { isSshPtyIdentityMismatchError } from '../../../providers/ssh-pty-errors' import type { RuntimePtySpawnState } from './spawn-state' +import { markRuntimeSpawnHiddenBeforeSpawn } from './spawn-hidden-delivery' export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise { const args = ctx.args @@ -42,6 +43,9 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise ) const expectedPtyId = stablePaneOwnerCandidate?.ptyId ?? ctx.effectiveSessionAppId ?? ctx.sessionId + if (!stablePaneOwnerCandidate) { + markRuntimeSpawnHiddenBeforeSpawn(ctx) + } if (expectedPtyId) { ctx.deps.runtime?.beginPtyRegistration?.(expectedPtyId) ctx.pendingRegistrationPtyId = expectedPtyId diff --git a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts new file mode 100644 index 000000000000..5795c213a53e --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts @@ -0,0 +1,60 @@ +import { closeStartupQueryAuthorityForPty } from '../provider/registry' +import type { RuntimePtySpawnState } from './spawn-state' + +// Why: a background runtime spawn has no renderer view until the user opens its tab, so nothing +// answers CPR/DA1 unless main owns the PTY as hidden (terminal-query-authority: main answers iff +// the hidden gate drops the chunk). Muse exits silently when its startup CPR goes unanswered. + +/** Daemon PTYs can emit before spawn() resolves, so the mark must precede byte zero. */ +export function markRuntimeSpawnHiddenBeforeSpawn(ctx: RuntimePtySpawnState): void { + // Why only fresh sessions: a reattached session id may already back a visible pane. + if ( + ctx.args.initiallyHidden !== true || + !ctx.isNewDaemonSession || + ctx.effectiveSessionAppId === undefined + ) { + return + } + ctx.preSpawnHiddenMarkId = ctx.effectiveSessionAppId + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState?.(ctx.preSpawnHiddenMarkId, true) +} + +/** Runs after commit: ptyOwnership must exist so backgrounded pacing can route to the provider. */ +export function commitRuntimeSpawnHiddenDelivery(ctx: RuntimePtySpawnState): void { + if (ctx.args.initiallyHidden !== true) { + return + } + const { id } = ctx.result + if ( + ctx.result.isReattach === true || + ctx.stablePaneOwner || + isAdoptedAgentSession(ctx.result.agentSessionEnsure) + ) { + releaseRuntimeSpawnPreSpawnHiddenMark(ctx) + return + } + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState?.(id, true) + if (ctx.preSpawnHiddenMarkId !== id) { + releaseRuntimeSpawnPreSpawnHiddenMark(ctx) + } + ctx.deps.syncPtyBackgroundedDelivery?.(id, 'spawn') + closeStartupQueryAuthorityForPty(id) +} + +/** A stale mark on a session id would gate a later visible attach that reuses it. */ +export function releaseRuntimeSpawnPreSpawnHiddenMark(ctx: RuntimePtySpawnState): void { + if (ctx.preSpawnHiddenMarkId === null) { + return + } + ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState?.(ctx.preSpawnHiddenMarkId, false) + ctx.preSpawnHiddenMarkId = null +} + +function isAdoptedAgentSession(ensure: unknown): boolean { + return ( + typeof ensure === 'object' && + ensure !== null && + 'disposition' in ensure && + ensure.disposition === 'adopted' + ) +} diff --git a/src/main/ipc/pty/runtime/spawn-state.ts b/src/main/ipc/pty/runtime/spawn-state.ts index d465556b1fcf..581d335ecdbc 100644 --- a/src/main/ipc/pty/runtime/spawn-state.ts +++ b/src/main/ipc/pty/runtime/spawn-state.ts @@ -78,6 +78,7 @@ export type RuntimePtySpawnState = { preparedProvisionalExecutionContext: boolean releaseWorktreeSpawn: (() => void) | undefined reportPtySpawnCommitted: () => void + preSpawnHiddenMarkId: string | null } export type RuntimePtySpawnArgs = { @@ -104,6 +105,8 @@ export type RuntimePtySpawnArgs = { sessionId?: string shellOverride?: string isNewSession?: boolean + /** No renderer view exists at spawn; main owns delivery and query replies until one mounts. */ + initiallyHidden?: boolean persistHostSessionBinding?: boolean expectedSourceBinding?: PtyBindingSourceExpectation terminalKittyKeyboardProtocol?: boolean @@ -182,6 +185,7 @@ export function createRuntimePtySpawnState( snapshotKittyFlagsCoverReconciledSeq: true, preparedProvisionalExecutionContext: false, releaseWorktreeSpawn: undefined, - reportPtySpawnCommitted: () => {} + reportPtySpawnCommitted: () => {}, + preSpawnHiddenMarkId: null } } diff --git a/src/main/ipc/pty/runtime/spawn.ts b/src/main/ipc/pty/runtime/spawn.ts index 2af050cd7963..5897cb1d9afa 100644 --- a/src/main/ipc/pty/runtime/spawn.ts +++ b/src/main/ipc/pty/runtime/spawn.ts @@ -16,6 +16,10 @@ import { buildRuntimePtySpawnOptions } from './spawn-options' import { executeRuntimePtySpawn } from './spawn-execute' import { commitRuntimePtySpawn } from './spawn-commit' import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' +import { + commitRuntimeSpawnHiddenDelivery, + releaseRuntimeSpawnPreSpawnHiddenMark +} from './spawn-hidden-delivery' function toRuntimeSpawnReply(result: { id: string @@ -95,8 +99,11 @@ export async function spawnPtyFromRuntimeController( return toRuntimeSpawnReply(earlyReserved) } await executeRuntimePtySpawn(ctx) - return toRuntimeSpawnReply(await commitRuntimePtySpawn(ctx)) + const committed = await commitRuntimePtySpawn(ctx) + commitRuntimeSpawnHiddenDelivery(ctx) + return toRuntimeSpawnReply(committed) } catch (err) { + releaseRuntimeSpawnPreSpawnHiddenMark(ctx) if (ctx.pendingRegistrationPtyId) { deps.runtime?.cancelPendingPtyRegistration?.( ctx.pendingRegistrationPtyId, diff --git a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts index b4e38fe71b3d..192e58c7a683 100644 --- a/src/main/runtime/cli-terminal-create-host-session-binding.test.ts +++ b/src/main/runtime/cli-terminal-create-host-session-binding.test.ts @@ -48,6 +48,14 @@ describe('host-initiated terminal creation under an attached window', () => { expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ persistHostSessionBinding: true })) }) + it('spawns hidden so main answers startup queries before any pane mounts', async () => { + const { runtime, spawn } = createRuntimeWithAttachedWindow() + + await runtime.createTerminal('id:repo-1::/tmp/wt-cli', {}) + + expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ initiallyHidden: true })) + }) + it('marks the spawned PTY runtime-session-owned', async () => { const { runtime } = createRuntimeWithAttachedWindow() diff --git a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts index 084733603659..282c7c79c396 100644 --- a/src/main/runtime/orca-runtime-create-terminal-dependencies.ts +++ b/src/main/runtime/orca-runtime-create-terminal-dependencies.ts @@ -24,3 +24,10 @@ export type { RuntimePtyController } from './runtime-pty-controller-contract' export { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' export { getRuntimeDesktopSurface } from './runtime-desktop-surface' export type { IpcMainEvent } from 'electron' + +// Why initiallyHidden: no renderer pane exists yet, so main must answer startup queries — Muse +// exits silently when its startup cursor-position query goes unanswered. +export const BACKGROUND_TERMINAL_SPAWN_FLAGS = { + initiallyHidden: true, + persistHostSessionBinding: true +} as const diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index 11e626601445..022ef810c844 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -172,7 +172,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate ...(adoptedBeforeLaunch ? { adoptedStablePane: adoptedBeforeLaunch } : {}), ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), ...(!adoptedBeforeLaunch && launchOpts.isNewSession ? { isNewSession: true } : {}), - persistHostSessionBinding: true + ...dependencies.BACKGROUND_TERMINAL_SPAWN_FLAGS }) } finally { releaseStablePaneCreate?.() diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 3ded7e44de0c..12b1466cacf1 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -61,6 +61,8 @@ export type RuntimePtyController = { /** Windows shell to spawn AS this PTY, instead of the host default. */ shellOverride?: string isNewSession?: boolean + /** No renderer view exists at spawn; main owns delivery and query replies until one mounts. */ + initiallyHidden?: boolean persistHostSessionBinding?: boolean expectedSourceBinding?: PtyBindingSourceExpectation terminalKittyKeyboardProtocol?: boolean From d2eaedf7157e6200213d40881da65a4983416d22 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 22 Sep 2026 21:28:14 -0700 Subject: [PATCH 2/3] fix(runtime): keep background PTYs hidden and paced across renderer reloads A runtime background spawn has no renderer pane to report visibility or re-mark it hidden, so it synced as foregrounded (no backpressure thinning) and a reload/crash gate reset cleared its hidden mark, leaving startup queries unanswered. Track runtime-owned hidden marks: they survive renderer-scoped resets, count as known-hidden for backgrounded pacing until a visible report, and are released by a renderer unmark or PTY teardown. --- src/main/ipc/pty-hidden-delivery-gate.test.ts | 15 +++++ src/main/ipc/pty-hidden-delivery-gate.ts | 23 ++++++- .../pty-runtime-hidden-at-spawn-mark.test.ts | 61 ++++++++++++++++++- src/main/ipc/pty/delivery/producer-sync.ts | 10 ++- .../ipc/pty/runtime/spawn-hidden-delivery.ts | 16 ++++- 5 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/main/ipc/pty-hidden-delivery-gate.test.ts b/src/main/ipc/pty-hidden-delivery-gate.test.ts index c85c4e2c8e08..d70eac4c87ee 100644 --- a/src/main/ipc/pty-hidden-delivery-gate.test.ts +++ b/src/main/ipc/pty-hidden-delivery-gate.test.ts @@ -5,6 +5,7 @@ import { getHiddenRendererPtyDeliveryDebug, isHiddenPtyDeliveryGateEnabled, markHiddenRendererPty, + markRuntimeOwnedHiddenRendererPty, recordHiddenRendererPtyDataDrop, resetRendererScopedHiddenPtyDeliveryState, setRendererPtyDeliveryInterest, @@ -94,6 +95,20 @@ describe('pty hidden delivery gate', () => { expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) }) + it('keeps runtime-owned marks across reload until unmark or teardown', () => { + markRuntimeOwnedHiddenRendererPty(PTY_ID) + markRuntimeOwnedHiddenRendererPty('pty-2') + + resetRendererScopedHiddenPtyDeliveryState() + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true) + + unmarkHiddenRendererPty(PTY_ID) + clearHiddenRendererPtyDeliveryState('pty-2') + resetRendererScopedHiddenPtyDeliveryState() + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + expect(shouldDropHiddenRendererPtyData('pty-2', {})).toBe(false) + }) + it('clears all per-PTY state on teardown and tracks debug counters', () => { markHiddenRendererPty(PTY_ID) setRendererPtyDeliveryInterest('pty-2', true) diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts index 0b199d0d1aba..c0d2030cf1b9 100644 --- a/src/main/ipc/pty-hidden-delivery-gate.ts +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -27,6 +27,10 @@ const deliveryInterestRendererPtys = new Set() // or full PTY teardown — never by re-marking hidden, so drop memory survives // hidden remounts and renderer reloads. const droppedSinceHiddenPtys = new Set() +// Why: a runtime background spawn has no renderer party to re-mark it after a +// reload/crash, so its hidden mark must outlive renderer-scoped resets until a +// renderer unmarks it (visible mount) or the PTY is torn down. +const runtimeOwnedHiddenRendererPtys = new Set() let droppedHiddenDeliveryChars = 0 let droppedHiddenDeliveryChunks = 0 @@ -54,6 +58,7 @@ export function markHiddenRendererPty(id: string): void { * the caller can emit a restore marker to the now-visible renderer. */ export function unmarkHiddenRendererPty(id: string): { droppedWhileHidden: boolean } { hiddenRendererPtys.delete(id) + runtimeOwnedHiddenRendererPtys.delete(id) const droppedWhileHidden = droppedSinceHiddenPtys.delete(id) return { droppedWhileHidden } } @@ -62,6 +67,16 @@ export function isHiddenRendererPty(id: string): boolean { return hiddenRendererPtys.has(id) } +/** Marks a PTY hidden on behalf of the runtime (no renderer view exists). */ +export function markRuntimeOwnedHiddenRendererPty(id: string): void { + hiddenRendererPtys.add(id) + runtimeOwnedHiddenRendererPtys.add(id) +} + +export function isRuntimeOwnedHiddenRendererPty(id: string): boolean { + return runtimeOwnedHiddenRendererPtys.has(id) +} + /** For freeze diagnostics only: hidden ptys must appear in the per-pty report * table even when the gate dropped every byte before any send/accounting. */ export function getHiddenRendererPtyIds(): string[] { @@ -105,19 +120,24 @@ export function recordHiddenRendererPtyDataDrop( /** Renderer process replaced (reload / crash): its ref-counted interest * holds and hidden marks died with it, so keeping them would gate (or - * force-feed) PTYs no live renderer party asked about. Drop memory is + * force-feed) PTYs no live renderer party asked about. Runtime-owned marks + * survive: no renderer party exists to re-mark them. Drop memory is * preserved — surviving daemon/SSH PTYs may have dropped bytes the old * renderer never restored; the new renderer's first hidden/visible sync * re-marks or unmarks and the unmark path re-emits the restore marker. */ export function resetRendererScopedHiddenPtyDeliveryState(): void { hiddenRendererPtys.clear() deliveryInterestRendererPtys.clear() + for (const id of runtimeOwnedHiddenRendererPtys) { + hiddenRendererPtys.add(id) + } } /** Full per-PTY teardown — wired into clearProviderPtyState so every exit * path (local, daemon, SSH, connection teardown) releases gate state. */ export function clearHiddenRendererPtyDeliveryState(id: string): void { hiddenRendererPtys.delete(id) + runtimeOwnedHiddenRendererPtys.delete(id) deliveryInterestRendererPtys.delete(id) droppedSinceHiddenPtys.delete(id) } @@ -146,6 +166,7 @@ export function resetHiddenRendererPtyDeliveryDebugCounters(): void { /** Test seam: reset all module state between tests. */ export function _resetHiddenRendererPtyDeliveryGateForTest(): void { hiddenRendererPtys.clear() + runtimeOwnedHiddenRendererPtys.clear() deliveryInterestRendererPtys.clear() droppedSinceHiddenPtys.clear() resetHiddenRendererPtyDeliveryDebugCounters() diff --git a/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts b/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts index e26e0b244cc9..d746d2640221 100644 --- a/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts +++ b/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts @@ -62,7 +62,22 @@ type RuntimeSpawnController = { const testDouble = (value: unknown): T => value as T describe('runtime-controller spawn: hidden until a renderer view mounts', () => { - const { mainWindow, installObservableDaemonTestProvider } = setupPtyIpcSuite() + const { + mainWindow, + installObservableDaemonTestProvider, + getPtySetHiddenRendererPtyListener, + getPtySetRendererPtyVisibleListener + } = setupPtyIpcSuite() + + function simulateRendererReload(): void { + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => testDouble<() => void>(call[1])) + expect(reloadHandlers.length).toBeGreaterThan(0) + for (const handler of reloadHandlers) { + handler() + } + } function createRuntimeMock() { return { @@ -105,6 +120,50 @@ describe('runtime-controller spawn: hidden until a renderer view mounts', () => expect(isHiddenRendererPty(result.id)).toBe(true) }) + it('paces a pane-less background PTY as backgrounded until a view reports visible', async () => { + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const controller = installController(runtime) + + const result = await controller.spawn({ cols: 80, rows: 24, initiallyHidden: true }) + expect(daemon.setPtyBackgrounded).toHaveBeenLastCalledWith(result.id, true) + + getPtySetRendererPtyVisibleListener()(null, { id: result.id, visible: true }) + expect(daemon.setPtyBackgrounded).toHaveBeenLastCalledWith(result.id, false) + }) + + it('keeps the runtime hidden mark and pacing across a renderer reload', async () => { + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const controller = installController(runtime) + + const result = await controller.spawn({ cols: 80, rows: 24, initiallyHidden: true }) + daemon.setPtyBackgrounded.mockClear() + simulateRendererReload() + + // No renderer party exists to re-mark it, so main must keep answering its queries. + expect(isHiddenRendererPty(result.id)).toBe(true) + expect(daemon.setPtyBackgrounded).not.toHaveBeenCalledWith(result.id, false) + }) + + it('hands the mark to the renderer once a visible view unmarks it', async () => { + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const controller = installController(runtime) + + const result = await controller.spawn({ cols: 80, rows: 24, initiallyHidden: true }) + getPtySetRendererPtyVisibleListener()(null, { id: result.id, visible: true }) + getPtySetHiddenRendererPtyListener()(null, { id: result.id, hidden: false }) + expect(isHiddenRendererPty(result.id)).toBe(false) + + daemon.setPtyBackgrounded.mockClear() + simulateRendererReload() + + // A reload must not resurrect a runtime mark on a PTY a renderer view already owned. + expect(isHiddenRendererPty(result.id)).toBe(false) + expect(daemon.setPtyBackgrounded).not.toHaveBeenCalledWith(result.id, true) + }) + it('clears the pre-spawn mark when the runtime spawn fails', async () => { const runtime = createRuntimeMock() const daemon = installObservableDaemonTestProvider() diff --git a/src/main/ipc/pty/delivery/producer-sync.ts b/src/main/ipc/pty/delivery/producer-sync.ts index b718e3cebe70..718ae2af2419 100644 --- a/src/main/ipc/pty/delivery/producer-sync.ts +++ b/src/main/ipc/pty/delivery/producer-sync.ts @@ -1,4 +1,5 @@ import { recordDaemonStreamBacklogEvent } from '../../../daemon/daemon-stream-backlog-probe' +import { isRuntimeOwnedHiddenRendererPty } from '../../pty-hidden-delivery-gate' import { tryGetProviderForPty } from '../provider/registry' import { PRODUCER_FLOW_CONTROL_ENABLED, @@ -25,9 +26,12 @@ export function syncPtyBackgroundedDelivery( id: string, caller: string ): void { - const background = - session.rendererPtyIsKnownHidden(id) && - !(session.runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) + // Why runtime-owned: a runtime background spawn has no pane to report visibility, but must pace + // like a renderer hidden-at-spawn PTY; any visible report still wins. + const knownHidden = + session.rendererPtyIsKnownHidden(id) || + (isRuntimeOwnedHiddenRendererPty(id) && !visibleRendererPtys.has(id)) + const background = knownHidden && !(session.runtime?.hasRawTerminalViewSubscriber?.(id) ?? false) if (session.backgroundedDeliverySyncByPty.get(id) === background) { return } diff --git a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts index 5795c213a53e..9d5d3dd4c319 100644 --- a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts +++ b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts @@ -1,3 +1,4 @@ +import { markRuntimeOwnedHiddenRendererPty } from '../../pty-hidden-delivery-gate' import { closeStartupQueryAuthorityForPty } from '../provider/registry' import type { RuntimePtySpawnState } from './spawn-state' @@ -16,7 +17,7 @@ export function markRuntimeSpawnHiddenBeforeSpawn(ctx: RuntimePtySpawnState): vo return } ctx.preSpawnHiddenMarkId = ctx.effectiveSessionAppId - ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState?.(ctx.preSpawnHiddenMarkId, true) + markRuntimeSpawnHidden(ctx, ctx.preSpawnHiddenMarkId) } /** Runs after commit: ptyOwnership must exist so backgrounded pacing can route to the provider. */ @@ -33,7 +34,7 @@ export function commitRuntimeSpawnHiddenDelivery(ctx: RuntimePtySpawnState): voi releaseRuntimeSpawnPreSpawnHiddenMark(ctx) return } - ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState?.(id, true) + markRuntimeSpawnHidden(ctx, id) if (ctx.preSpawnHiddenMarkId !== id) { releaseRuntimeSpawnPreSpawnHiddenMark(ctx) } @@ -50,6 +51,17 @@ export function releaseRuntimeSpawnPreSpawnHiddenMark(ctx: RuntimePtySpawnState) ctx.preSpawnHiddenMarkId = null } +function markRuntimeSpawnHidden(ctx: RuntimePtySpawnState, id: string): void { + const transition = ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState + if (!transition) { + return + } + // Transition first so a fresh mark still invalidates the drain policy. + transition(id, true) + // Why runtime-owned: no renderer party re-marks this PTY after a reload/crash gate reset. + markRuntimeOwnedHiddenRendererPty(id) +} + function isAdoptedAgentSession(ensure: unknown): boolean { return ( typeof ensure === 'object' && From 1806b86ddf773fa725e1ef6a6a9125e031e45263 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 22 Sep 2026 22:09:06 -0700 Subject: [PATCH 3/3] fix(runtime): don't re-hide a background PTY whose view mounted visible during spawn --- .../pty-runtime-hidden-at-spawn-mark.test.ts | 33 ++++++++++++++++++- .../ipc/pty/runtime/spawn-hidden-delivery.ts | 7 ++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts b/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts index d746d2640221..b4deb243b0b3 100644 --- a/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts +++ b/src/main/ipc/pty-runtime-hidden-at-spawn-mark.test.ts @@ -66,7 +66,8 @@ describe('runtime-controller spawn: hidden until a renderer view mounts', () => mainWindow, installObservableDaemonTestProvider, getPtySetHiddenRendererPtyListener, - getPtySetRendererPtyVisibleListener + getPtySetRendererPtyVisibleListener, + getPtyDataSendCalls } = setupPtyIpcSuite() function simulateRendererReload(): void { @@ -164,6 +165,36 @@ describe('runtime-controller spawn: hidden until a renderer view mounts', () => expect(daemon.setPtyBackgrounded).not.toHaveBeenCalledWith(result.id, true) }) + it('does not re-hide a PTY whose view mounted visible before spawn committed', async () => { + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const spawnGate = makeDeferred() + let mintedSessionId: string | undefined + daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => { + mintedSessionId = options.sessionId + await spawnGate.promise + return { id: options.sessionId ?? 'daemon-pty' } + }) + const controller = installController(runtime) + + const spawnPromise = controller.spawn({ cols: 80, rows: 24, initiallyHidden: true }) + await vi.waitFor(() => expect(mintedSessionId).toBeDefined()) + // The visible mount reports visible and releases the pre-spawn mark. + getPtySetRendererPtyVisibleListener()(null, { id: mintedSessionId!, visible: true }) + getPtySetHiddenRendererPtyListener()(null, { id: mintedSessionId!, hidden: false }) + spawnGate.resolve() + const result = await spawnPromise + + expect(isHiddenRendererPty(result.id)).toBe(false) + daemon.emitData(result.id, 'visible output') + await vi.waitFor(() => + expect(getPtyDataSendCalls()).toContainEqual([ + 'pty:data', + expect.objectContaining({ id: result.id, data: 'visible output' }) + ]) + ) + }) + it('clears the pre-spawn mark when the runtime spawn fails', async () => { const runtime = createRuntimeMock() const daemon = installObservableDaemonTestProvider() diff --git a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts index 9d5d3dd4c319..773215d10de4 100644 --- a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts +++ b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts @@ -1,4 +1,5 @@ import { markRuntimeOwnedHiddenRendererPty } from '../../pty-hidden-delivery-gate' +import { visibleRendererPtys } from '../delivery/visibility-state' import { closeStartupQueryAuthorityForPty } from '../provider/registry' import type { RuntimePtySpawnState } from './spawn-state' @@ -34,6 +35,12 @@ export function commitRuntimeSpawnHiddenDelivery(ctx: RuntimePtySpawnState): voi releaseRuntimeSpawnPreSpawnHiddenMark(ctx) return } + // Why: a view that mounted visible during spawn already unmarked; re-marking would drop its output. + if (visibleRendererPtys.has(id)) { + releaseRuntimeSpawnPreSpawnHiddenMark(ctx) + ctx.deps.syncPtyBackgroundedDelivery?.(id, 'spawn') + return + } markRuntimeSpawnHidden(ctx, id) if (ctx.preSpawnHiddenMarkId !== id) { releaseRuntimeSpawnPreSpawnHiddenMark(ctx)