mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 08:02:52 +00:00
fix(runtime): hydrate the host model on a phone subscribe to an idle PTY
After a relaunch the reattach leaves the host model empty and an idle TUI never paints the byte that hydrates it. A phone subscribing then got the hidden desktop pane's screen at desktop size, missed its viewport on every resubscribe, and gave up with "Couldn't fit the terminal to this screen". The subscribe now runs the existing renderer hydrate after the phone fit, so the pane's screen is reflowed onto the PTY grid, and serves the phone from that model instead of adopting the pane's desktop-sized screen. Claude-Session: lane-host-hydrate
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* A phone subscribing to an idle reattached PTY must get its first snapshot on the phone grid.
|
||||
*
|
||||
* Harness: real OrcaRuntimeService + real legacy `terminal.subscribe`. After a relaunch the
|
||||
* reattach skips seeding the host model because a pane is mounted; the PTY then emits no byte
|
||||
* (an agent waiting for input), and the pane, hidden in another workspace, answers its
|
||||
* serializer at desktop size.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import type { RpcRequest } from './rpc/core'
|
||||
import { TERMINAL_METHODS } from './rpc/methods/terminal'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
decodeTerminalStreamText
|
||||
} from '../../shared/terminal-stream-protocol'
|
||||
import { HeadlessEmulator } from '../daemon/headless-emulator'
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/tmp/wt'
|
||||
const PTY_ID = `${WORKTREE_ID}@@9c8d7e6f`
|
||||
const DESKTOP = { cols: 200, rows: 50 }
|
||||
const PHONE = { cols: 47, rows: 40 }
|
||||
const LONG_LINE = 'A'.repeat(120)
|
||||
const EXPECTED_PHONE_ROWS = ['A'.repeat(47), 'A'.repeat(47), 'A'.repeat(26), '$ prompt']
|
||||
|
||||
type RuntimeInternals = {
|
||||
recordPtyWorktree: (ptyId: string, worktreeId: string, state?: { connected?: boolean }) => unknown
|
||||
issuePtyHandle: (pty: unknown) => string
|
||||
providerSnapshotPreferredPtys: Set<string>
|
||||
}
|
||||
|
||||
function internals(runtime: OrcaRuntimeService): RuntimeInternals {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test reaches protected members the runtime defines.
|
||||
return runtime as unknown as RuntimeInternals
|
||||
}
|
||||
|
||||
async function screenOn(grid: { cols: number; rows: number }): Promise<string> {
|
||||
const emulator = new HeadlessEmulator({ ...grid, scrollback: 1000 })
|
||||
try {
|
||||
await emulator.write(`${LONG_LINE}\r\n$ prompt`)
|
||||
const snapshot = emulator.getSnapshot()
|
||||
return snapshot.rehydrateSequences + snapshot.snapshotAnsi
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function setup(opts: { paneMounted: boolean; providerSnapshot?: 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()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
getSize: (ptyId: string) => sizes.get(ptyId) ?? null,
|
||||
resize: (ptyId: string, cols: number, rows: number) => {
|
||||
sizes.set(ptyId, { cols, rows })
|
||||
return true
|
||||
},
|
||||
hasRendererSerializer: () => opts.paneMounted,
|
||||
getRendererSerializerGeneration: () => 1,
|
||||
waitForRendererSerializer: async () => false,
|
||||
serializeBuffer,
|
||||
// The daemon resizes its model with the PTY.
|
||||
serializeProviderBuffer: async (ptyId: string) => {
|
||||
const grid = sizes.get(ptyId) ?? DESKTOP
|
||||
return opts.providerSnapshot
|
||||
? { data: await screenOn(grid), ...grid, seq: 0, source: 'headless' as const }
|
||||
: null
|
||||
}
|
||||
})
|
||||
const requestMount = vi.spyOn(runtime, 'requestRendererTerminalTabMount').mockReturnValue(false)
|
||||
const record = internals(runtime).recordPtyWorktree(PTY_ID, WORKTREE_ID, { connected: true })
|
||||
const handle = internals(runtime).issuePtyHandle(record)
|
||||
return { runtime, handle, sizes, serializeBuffer, requestMount }
|
||||
}
|
||||
|
||||
function subscribePhone(runtime: OrcaRuntimeService, handle: string) {
|
||||
const frames: Uint8Array<ArrayBufferLike>[] = []
|
||||
const controller = new AbortController()
|
||||
const request: RpcRequest = {
|
||||
id: 'req-phone',
|
||||
authToken: 'tok',
|
||||
method: 'terminal.subscribe',
|
||||
params: {
|
||||
terminal: handle,
|
||||
client: { id: 'phone-1', type: 'mobile' },
|
||||
viewport: PHONE,
|
||||
capabilities: { terminalBinaryStream: 1 }
|
||||
}
|
||||
}
|
||||
const done = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }).dispatchStreaming(
|
||||
request,
|
||||
() => {},
|
||||
{
|
||||
connectionId: 'conn-phone',
|
||||
signal: controller.signal,
|
||||
sendBinary: (bytes) => {
|
||||
frames.push(bytes)
|
||||
},
|
||||
registerBinaryStreamHandler: () => () => {}
|
||||
}
|
||||
)
|
||||
const decoded = () => frames.flatMap((bytes) => decodeTerminalStreamFrame(bytes) ?? [])
|
||||
const snapshot = () => {
|
||||
const start = decoded().find((frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart)
|
||||
const meta = start
|
||||
? decodeTerminalStreamJson<{ cols: number; rows: number }>(start.payload)
|
||||
: null
|
||||
if (!meta) {
|
||||
return null
|
||||
}
|
||||
const data = decoded()
|
||||
.filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk)
|
||||
.map((frame) => decodeTerminalStreamText(frame.payload))
|
||||
.join('')
|
||||
return { cols: meta.cols, rows: meta.rows, data }
|
||||
}
|
||||
const close = async () => {
|
||||
runtime.cleanupSubscription(`${handle}:phone-1`)
|
||||
controller.abort()
|
||||
await done.catch(() => {})
|
||||
}
|
||||
return { snapshot, close }
|
||||
}
|
||||
|
||||
/** What a phone xterm sized to the frame's declared grid shows after replaying it. */
|
||||
async function paintedRows(snapshot: { cols: number; rows: number; data: string }) {
|
||||
const emulator = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows, scrollback: 0 })
|
||||
try {
|
||||
await emulator.write(snapshot.data)
|
||||
return emulator.getVisibleLines().map((line) => line.trimEnd())
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function firstSnapshot(runtime: OrcaRuntimeService, handle: string) {
|
||||
const subscription = subscribePhone(runtime, handle)
|
||||
await vi.waitFor(() => expect(subscription.snapshot()).not.toBeNull())
|
||||
const snapshot = subscription.snapshot()
|
||||
await subscription.close()
|
||||
if (!snapshot) {
|
||||
throw new Error('no snapshot frame')
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
describe('phone subscribe to an idle PTY whose hidden pane sits at desktop size', () => {
|
||||
it('serves the first snapshot from a host model hydrated onto the phone grid', async () => {
|
||||
const { runtime, handle, sizes } = setup({ paneMounted: true })
|
||||
expect(runtime.hasHeadlessTerminalState(PTY_ID)).toBe(false)
|
||||
|
||||
const snapshot = await firstSnapshot(runtime, handle)
|
||||
|
||||
expect(sizes.get(PTY_ID)).toEqual(PHONE)
|
||||
expect({ cols: snapshot.cols, rows: snapshot.rows }).toEqual(PHONE)
|
||||
expect((await paintedRows(snapshot)).slice(0, 4)).toEqual(EXPECTED_PHONE_ROWS)
|
||||
expect(runtime.hasHeadlessTerminalState(PTY_ID)).toBe(true)
|
||||
})
|
||||
|
||||
it('serves the phone grid when a reattach left the restored snapshot preferred', async () => {
|
||||
const { runtime, handle } = setup({ paneMounted: true, providerSnapshot: true })
|
||||
// A continued daemon generation marks the model unsafe until a full snapshot arrives.
|
||||
internals(runtime).providerSnapshotPreferredPtys.add(PTY_ID)
|
||||
|
||||
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)
|
||||
const resubscribed = await firstSnapshot(runtime, handle)
|
||||
expect({ cols: resubscribed.cols, rows: resubscribed.rows }).toEqual(PHONE)
|
||||
})
|
||||
|
||||
it('keeps serving the model on resubscribe without re-reading the pane', async () => {
|
||||
const { runtime, handle, serializeBuffer } = setup({ paneMounted: true })
|
||||
await firstSnapshot(runtime, handle)
|
||||
const panesReadForFirst = serializeBuffer.mock.calls.length
|
||||
|
||||
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.mock.calls.length).toBe(panesReadForFirst)
|
||||
})
|
||||
|
||||
it('chains a live repaint after the hydrated seed', async () => {
|
||||
const { runtime, handle } = setup({ paneMounted: true })
|
||||
await firstSnapshot(runtime, handle)
|
||||
runtime.onPtyData(PTY_ID, '\r\n$ repainted', Date.now())
|
||||
|
||||
const snapshot = await firstSnapshot(runtime, handle)
|
||||
|
||||
expect((await paintedRows(snapshot)).slice(0, 5)).toEqual([
|
||||
...EXPECTED_PHONE_ROWS,
|
||||
'$ repainted'
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a PTY with no pane serializer to the renderer mount path', async () => {
|
||||
const { runtime, handle, requestMount } = setup({ paneMounted: false })
|
||||
|
||||
const subscription = subscribePhone(runtime, handle)
|
||||
await vi.waitFor(() => expect(subscription.snapshot()).not.toBeNull(), { timeout: 5_000 })
|
||||
await subscription.close()
|
||||
|
||||
expect(requestMount).toHaveBeenCalledTimes(1)
|
||||
expect(runtime.hasHeadlessTerminalState(PTY_ID)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -13,24 +13,24 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime
|
||||
// is populated synchronously so concurrent live writes from
|
||||
// trackHeadlessTerminalData chain after the seed via the same writeChain.
|
||||
// See docs/mobile-prefer-renderer-scrollback.md.
|
||||
protected maybeHydrateHeadlessFromRenderer(ptyId: string): void {
|
||||
protected maybeHydrateHeadlessFromRenderer(ptyId: string): Promise<boolean> | null {
|
||||
if (this.headlessHydrationState.has(ptyId)) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
const providerSnapshotPreferred = this.providerSnapshotPreferredPtys.has(ptyId)
|
||||
if (this.headlessTerminals.has(ptyId) && !providerSnapshotPreferred) {
|
||||
// Daemon-snapshot seed already populated the emulator — skip hydration.
|
||||
this.headlessHydrationState.set(ptyId, 'done')
|
||||
return
|
||||
return null
|
||||
}
|
||||
const controller = this.ptyController
|
||||
if (!controller?.serializeBuffer || !controller.hasRendererSerializer) {
|
||||
return
|
||||
return null
|
||||
}
|
||||
if (!controller.hasRendererSerializer(ptyId)) {
|
||||
// Renderer hasn't registered yet (or never will). Live writes lazy-
|
||||
// create the state via trackHeadlessTerminalData on this same tick.
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
if (providerSnapshotPreferred) {
|
||||
@@ -52,6 +52,7 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime
|
||||
// execute AFTER the seed-write resolves. If we awaited inline before
|
||||
// setting headlessTerminals, the live byte would lazy-create a separate
|
||||
// state and the seed-resolve would overwrite it, dropping live bytes.
|
||||
let seeded = false
|
||||
state.writeChain = state.writeChain.then(async () => {
|
||||
if (this.headlessTerminals.get(ptyId) !== state) {
|
||||
return
|
||||
@@ -99,6 +100,7 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime
|
||||
this.applySeededAgentStatus(ptyId, seedTitle)
|
||||
}
|
||||
this.providerSnapshotPreferredPtys.delete(ptyId)
|
||||
seeded = true
|
||||
} catch {
|
||||
// Hydration is best-effort. Live writes continue via the same
|
||||
// writeChain that this catch-arm leaves intact.
|
||||
@@ -108,6 +110,13 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime
|
||||
}
|
||||
}
|
||||
})
|
||||
return state.writeChain.then(() => seeded)
|
||||
}
|
||||
|
||||
/** 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: seed-derived agent status reflects historical state. Orchestration
|
||||
|
||||
@@ -46,6 +46,13 @@ export async function publishLegacyBinaryInitialSnapshot(
|
||||
return
|
||||
}
|
||||
|
||||
// Why after the fit: the seed reflows the pane's screen onto the grid the phone now owns.
|
||||
const hydratedFromRenderer =
|
||||
missingHeadlessStateBeforeMobileFit &&
|
||||
(await runtime.hydrateHeadlessTerminalFromRenderer?.(ptyId)) === true
|
||||
if (state.closed) {
|
||||
return
|
||||
}
|
||||
let read = await runtime.readTerminal(params.terminal)
|
||||
// One object for the budget and the frame it approves. Written out twice, the two drifted: the
|
||||
// budget measured a `scrollback` and the publication sent a `resized` with a `reason` beside it.
|
||||
@@ -63,7 +70,9 @@ export async function publishLegacyBinaryInitialSnapshot(
|
||||
}
|
||||
// Why: missing model state (not blank snapshot text) signals a never-attached PTY; any renderer answer proves a pane, and the answer is null when the host's flag is unset or stale after a pane closed over a live PTY, which falls back to the mount wait.
|
||||
const needsRendererScreen =
|
||||
missingHeadlessStateBeforeMobileFit && serialized?.source !== 'renderer'
|
||||
missingHeadlessStateBeforeMobileFit &&
|
||||
!hydratedFromRenderer &&
|
||||
serialized?.source !== 'renderer'
|
||||
let rendererReady =
|
||||
needsRendererScreen &&
|
||||
(await runtime.serializeRendererTerminalBuffer(ptyId, { scrollbackRows: 0 })) !== null
|
||||
|
||||
Reference in New Issue
Block a user