From 9f9dbcf6d3b2bd0bfbf1f6530d4f98d6597d43ce Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:23:02 -0800 Subject: [PATCH] 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 --- .../adapter/daemon-pty-session-inventory.ts | 3 +- ...aemon-pty-adapter-session-adoption.test.ts | 6 +++ ...al-subscriber-driven-daemon-attach.test.ts | 52 +++++++++++++++++++ .../terminal/identity/pty-worktree-record.ts | 2 +- .../launch/terminal-create-deduplication.ts | 2 + 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/main/daemon/providers/adapter/daemon-pty-session-inventory.ts b/src/main/daemon/providers/adapter/daemon-pty-session-inventory.ts index 45eb38722ce..f053d3eb482 100644 --- a/src/main/daemon/providers/adapter/daemon-pty-session-inventory.ts +++ b/src/main/daemon/providers/adapter/daemon-pty-session-inventory.ts @@ -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 } : {}), diff --git a/src/main/daemon/providers/adapter/testing/daemon-pty-adapter-session-adoption.test.ts b/src/main/daemon/providers/adapter/testing/daemon-pty-adapter-session-adoption.test.ts index 7b6e966c3df..0beb3198ad3 100644 --- a/src/main/daemon/providers/adapter/testing/daemon-pty-adapter-session-adoption.test.ts +++ b/src/main/daemon/providers/adapter/testing/daemon-pty-adapter-session-adoption.test.ts @@ -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 }) diff --git a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts index 77e13a45b90..46f496709f3 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -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 // 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, diff --git a/src/main/runtime/terminal/identity/pty-worktree-record.ts b/src/main/runtime/terminal/identity/pty-worktree-record.ts index c6ca08d2156..c402cfdf2c4 100644 --- a/src/main/runtime/terminal/identity/pty-worktree-record.ts +++ b/src/main/runtime/terminal/identity/pty-worktree-record.ts @@ -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, diff --git a/src/main/runtime/terminal/launch/terminal-create-deduplication.ts b/src/main/runtime/terminal/launch/terminal-create-deduplication.ts index 8b6b06a7465..a26ef398aa7 100644 --- a/src/main/runtime/terminal/launch/terminal-create-deduplication.ts +++ b/src/main/runtime/terminal/launch/terminal-create-deduplication.ts @@ -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,