mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
fix(terminal): adopted daemon terminals take title and output from their stream (#38760)
The daemon adapter's inventory listing reported every surviving session's title as "shell", though the listing carries no title. That made-up value became the fallback tab title when adoption ran before the attach seed, and a terminal.create retry that adopted a survivor after a restart recorded it as the terminal's observed title and returned it. That create-retry adoption also never attached the session's stream, so the adopted terminal's output never reached main: lastOutputAt stayed null and its real title never landed until some later inventory refresh attached it. - The daemon listing reports no title. The attach snapshot's lastTitle and the stream stay the single source, as for every other terminal. - A create retry that adopts a survivor attaches it through the same main attachment inventory adoption uses. - A new runtime record treats an empty title as unknown, as an update already does, so it never reads as a manual title. Inventory adoption already attaches survivors (#36137); this closes the create retry path and drops the made-up title. No wire change: old and new daemons send the same listing. Fixes #22809
This commit is contained in:
@@ -56,7 +56,8 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti
|
||||
...(session.pid ? { rootProcessId: session.pid } : {}),
|
||||
// Spawn cwd remains authoritative until the daemon reports live cwd.
|
||||
cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '',
|
||||
title: 'shell',
|
||||
// The listing carries no title; attach seeds the live one from the daemon snapshot.
|
||||
title: '',
|
||||
...(session.foregroundProcess
|
||||
? { foregroundProcess: session.foregroundProcess }
|
||||
: {}),
|
||||
|
||||
@@ -297,6 +297,12 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reports no title, since a made-up one would read as the terminal’s own', async () => {
|
||||
await adapter.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
expect((await adapter.listProcesses()).map((row) => row.title)).toEqual([''])
|
||||
})
|
||||
|
||||
// Reconcile missed disconnect-time exits so cached hasPty results cannot block absence proofs.
|
||||
it('drops cached session ids an authoritative inventory omits', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
@@ -3,6 +3,7 @@ import './rpc/unused-default-rpc-methods.test-fixture'
|
||||
* Exercise real runtime/multiplex with controller data gated on attach; no renderer is present. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { deriveRemoteRuntimeTerminalCreateHandle } from './terminal/identity/remote-runtime-terminal-create-identity'
|
||||
import { RpcDispatcher } from './rpc/dispatcher'
|
||||
import type { RpcRequest } from './rpc/core'
|
||||
import { TERMINAL_METHODS } from './rpc/methods/terminal'
|
||||
@@ -31,6 +32,7 @@ type RuntimeInternals = {
|
||||
) => Promise<unknown>
|
||||
// Optional so this suite still runs, and fails on behavior, against a runtime without it.
|
||||
notePtyEndpointLoss?: (ptyId: string) => void
|
||||
resolveTerminalWorkspaceLaunchScope: () => Promise<{ id: string; connectionId: null }>
|
||||
}
|
||||
|
||||
function internals(runtime: OrcaRuntimeService): RuntimeInternals {
|
||||
@@ -42,6 +44,8 @@ type DaemonSessionModel = {
|
||||
rows: number
|
||||
attached: boolean
|
||||
screen: string
|
||||
terminalHandle?: string
|
||||
lastTitle?: string
|
||||
}
|
||||
|
||||
/** Models the daemon provider boundary: inventory of live sessions, data
|
||||
@@ -70,6 +74,7 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) {
|
||||
cwd: '/tmp/wt',
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: '',
|
||||
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
|
||||
cols: session.cols,
|
||||
rows: session.rows
|
||||
})),
|
||||
@@ -98,6 +103,10 @@ function createDaemonProviderModel(opts: { snapshotCapable: boolean }) {
|
||||
return false
|
||||
}
|
||||
session.attached = true
|
||||
// The runtime controller seeds the attach snapshot's title, as attachPtyFromRuntimeController does.
|
||||
if (session.lastTitle) {
|
||||
runtime?.seedTerminalRestoreTail(ptyId, { lastTitle: session.lastTitle })
|
||||
}
|
||||
return true
|
||||
},
|
||||
serializeProviderBuffer: async (ptyId: string) => {
|
||||
@@ -448,6 +457,49 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => {
|
||||
expect(read.tail.join('\n')).toContain('worker still printing')
|
||||
})
|
||||
|
||||
it('streams a survivor a terminal.create retry adopts after a restart', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const model = createDaemonProviderModel({ snapshotCapable: false })
|
||||
model.bind(runtime)
|
||||
runtime.setPtyController(model.controller as never)
|
||||
vi.spyOn(internals(runtime), 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
|
||||
id: WORKTREE_ID,
|
||||
connectionId: null
|
||||
})
|
||||
model.sessions.set(PTY_ID, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
attached: false,
|
||||
screen: '',
|
||||
terminalHandle: deriveRemoteRuntimeTerminalCreateHandle(
|
||||
'device-a',
|
||||
WORKTREE_ID,
|
||||
'mutation-1'
|
||||
),
|
||||
lastTitle: '✳ Claude Code'
|
||||
})
|
||||
const spawn = vi.fn()
|
||||
|
||||
const adopted = await runtime.dedupeTerminalCreate(
|
||||
'device-a',
|
||||
`id:${WORKTREE_ID}`,
|
||||
'mutation-1',
|
||||
true,
|
||||
spawn
|
||||
)
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID]))
|
||||
expect(model.emitData(PTY_ID, 'still working\r\n')).toBe(true)
|
||||
const [listed] = (await runtime.listTerminals()).terminals
|
||||
expect(listed).toMatchObject({
|
||||
handle: adopted.handle,
|
||||
title: '✳ Claude Code',
|
||||
preview: 'still working'
|
||||
})
|
||||
expect(listed.lastOutputAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not subscriber-attach or provider-read a session this app already spawned', async () => {
|
||||
const { runtime, model, handle } = setupNeverAttachedDaemonSession({
|
||||
snapshotCapable: true,
|
||||
|
||||
@@ -132,7 +132,7 @@ export abstract class OrcaRuntimeWithRecordPtyWorktree extends RuntimeWorkspaceR
|
||||
managementTitle: null,
|
||||
managementTitleAt: null,
|
||||
controllerTitle: null,
|
||||
title: state.title ?? null,
|
||||
title: state.title || null,
|
||||
titleUpdatedAt: titleObservedAt,
|
||||
titleObservedAtEpochMs: titleObservedAt !== null ? Date.now() : null,
|
||||
lastOutputAt: state.lastOutputAt ?? null,
|
||||
|
||||
@@ -144,6 +144,8 @@ export abstract class OrcaRuntimeWithTerminalCreateDeduplication extends OrcaRun
|
||||
if (adoptedHandle !== terminalHandle) {
|
||||
throw new Error('terminal_create_identity_conflict')
|
||||
}
|
||||
// Same as inventory adoption: the stream carries this survivor's title and output.
|
||||
this.mainDaemonAttachments.reconcile(session.id)
|
||||
return {
|
||||
handle: adoptedHandle,
|
||||
ptyId: session.id,
|
||||
|
||||
Reference in New Issue
Block a user