fix(runtime): join a renderer seed the resize repaint already started

A TUI answering the phone fit's SIGWINCH can deliver a byte before the
subscribe reaches its hydrate. That byte runs the same renderer seed, so
the subscribe saw "already hydrating", fell back to adopting the pane's
desktop-sized screen and seeded the model at desktop size again. Keep the
model's renderer seed beside it so the subscribe joins that result.

Claude-Session: lane-host-hydrate
This commit is contained in:
Jinwoo-H
2026-10-06 00:47:38 -04:00
parent d0e42bf331
commit 944b57b8d8
5 changed files with 41 additions and 6 deletions
@@ -48,12 +48,19 @@ async function screenOn(grid: { cols: number; rows: number }): Promise<string> {
}
}
function setup(opts: { paneMounted: boolean; providerSnapshot?: boolean }) {
function setup(opts: {
paneMounted: boolean
providerSnapshot?: boolean
repaintOnResize?: boolean
}) {
const sizes = new Map([[PTY_ID, { ...DESKTOP }]])
const serializeBuffer = vi.fn(async () =>
opts.paneMounted ? { data: await screenOn(DESKTOP), ...DESKTOP } : null
)
const runtime = new OrcaRuntimeService()
// The pane orders its screen against PTY output, as a mounted desktop xterm does.
const serializeBuffer = vi.fn(async () =>
opts.paneMounted
? { data: await screenOn(DESKTOP), ...DESKTOP, seq: runtime.getPtyOutputSequence(PTY_ID) }
: null
)
runtime.setPtyController({
write: () => true,
kill: () => true,
@@ -61,6 +68,10 @@ function setup(opts: { paneMounted: boolean; providerSnapshot?: boolean }) {
getSize: (ptyId: string) => sizes.get(ptyId) ?? null,
resize: (ptyId: string, cols: number, rows: number) => {
sizes.set(ptyId, { cols, rows })
if (opts.repaintOnResize) {
// A TUI answering SIGWINCH before the subscribe reaches its own hydrate.
runtime.onPtyData(ptyId, '\x1b[?25h', Date.now())
}
return true
},
hasRendererSerializer: () => opts.paneMounted,
@@ -178,6 +189,16 @@ describe('phone subscribe to an idle PTY whose hidden pane sits at desktop size'
expect({ cols: resubscribed.cols, rows: resubscribed.rows }).toEqual(PHONE)
})
it('joins a hydrate the resize repaint already started', async () => {
const { runtime, handle, serializeBuffer } = setup({ paneMounted: true, repaintOnResize: true })
const snapshot = await firstSnapshot(runtime, handle)
expect({ cols: snapshot.cols, rows: snapshot.rows }).toEqual(PHONE)
expect((await paintedRows(snapshot)).slice(0, 4)).toEqual(EXPECTED_PHONE_ROWS)
expect(serializeBuffer).toHaveBeenCalledTimes(1)
})
it('keeps serving the model on resubscribe without re-reading the pane', async () => {
const { runtime, handle, serializeBuffer } = setup({ paneMounted: true })
await firstSnapshot(runtime, handle)
@@ -130,6 +130,8 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ
// Absent → hydration has not been considered yet for this PTY.
// See docs/mobile-prefer-renderer-scrollback.md.
protected headlessHydrationState = new Map<string, 'pending' | 'done'>()
/** The model's renderer seed, resolving true once written; lives as long as the model. */
protected rendererSeedsByPtyId = new Map<string, Promise<boolean>>()
// Why: mobile-fit overrides are keyed by ptyId (not terminal handle) because
// handles can be reissued while the PTY identity is stable. In-memory only —
@@ -110,13 +110,20 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime
}
}
})
return state.writeChain.then(() => seeded)
const seed = state.writeChain.then(() => seeded)
this.rendererSeedsByPtyId.set(ptyId, seed)
return seed
}
/** Public: a phone fit resizes the PTY, but an idle TUI sends no byte to hydrate on. Resolves
* true once the model holds the pane's screen reflowed onto the PTY's current grid. */
hydrateHeadlessTerminalFromRenderer(ptyId: string): Promise<boolean> {
return this.maybeHydrateHeadlessFromRenderer(ptyId) ?? Promise.resolve(false)
// Why join: a resize repaint can run the on-data seed first; its result answers this caller too.
return (
this.maybeHydrateHeadlessFromRenderer(ptyId) ??
this.rendererSeedsByPtyId.get(ptyId) ??
Promise.resolve(false)
)
}
// Why: seed-derived agent status reflects historical state. Orchestration
@@ -75,6 +75,7 @@ export class OrcaRuntimeWithSerializeHeadlessTerminalBuffer extends OrcaRuntimeW
protected disposeHeadlessTerminal(ptyId: string): void {
this.headlessHydrationState.delete(ptyId)
this.rendererSeedsByPtyId.delete(ptyId)
const state = this.headlessTerminals.get(ptyId)
if (!state) {
return
@@ -41,6 +41,10 @@ const CLEARED_BY_REAPER_HELPER: Record<string, { helper: string; module: string
agentPromptPermissionSequenceByPtyId: {
helper: 'advancePtyLifecycleGeneration',
module: 'src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts'
},
rendererSeedsByPtyId: {
helper: 'disposeHeadlessTerminal',
module: 'src/main/runtime/orca-runtime-serialize-headless-terminal-buffer.ts'
}
}