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:
Neil
2026-11-05 22:23:02 -08:00
committed by GitHub
parent 5f845b6024
commit 9f9dbcf6d3
5 changed files with 63 additions and 2 deletions
@@ -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,