From 595553eb5eee74be073bf0df5ad31d813e677f7f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sat, 8 Aug 2026 04:20:49 -0400 Subject: [PATCH] fix(terminal): preserve daemon sequence on subscriber attach --- src/main/daemon/daemon-pty-adapter.test.ts | 14 ++++++++++ src/main/daemon/daemon-pty-adapter.ts | 8 ++++-- src/main/daemon/daemon-pty-router.ts | 4 +-- src/main/ipc/pty.test.ts | 28 +++++++++++++++++-- src/main/ipc/pty.ts | 10 ++++++- src/main/providers/pty-provider-contract.ts | 2 +- ...minal-host-restart-background-sync.spec.ts | 11 ++------ 7 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index b769f1e88ef..d8d07305299 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -1506,6 +1506,20 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.snapshot).toContain('prompt$') }) + it('returns the preserved sequence from attach-only adoption', async () => { + const sessionId = 'attach-sequence-handoff' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + lastSubprocess._simulateData('preserved output') + await new Promise((r) => setTimeout(r, 50)) + + await expect(adapter.attach(sessionId)).resolves.toEqual({ + providerSequence: { + value: 'preserved output'.length, + generation: 'continued' + } + }) + }) + it('returns plain result for new sessionId', async () => { const result = await adapter.spawn({ cols: 80, rows: 24, sessionId: 'brand-new' }) expect(result.id).toBe('brand-new') diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 03fbcc1e278..f8bdf30deab 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -108,7 +108,7 @@ function takeRecoveryFreeze( historyRecovery.freeze = null return freeze } -function providerSequenceForSpawn( +function providerSequenceFromCreateOrAttach( result: CreateOrAttachResult ): PtySpawnResult['providerSequence'] { if (result.isNew) { @@ -731,7 +731,7 @@ export class DaemonPtyAdapter implements IPtyProvider { const wasAlreadyManaged = this.activeSessionIds.has(sessionId) this.activeSessionIds.add(sessionId) - const providerSequence = providerSequenceForSpawn(result) + const providerSequence = providerSequenceFromCreateOrAttach(result) // Cold restore: daemon made a new session but disk history shows an unclean shutdown → return saved scrollback. if (restoreInfo && (result.isNew || result.historySeeded === false)) { @@ -892,7 +892,7 @@ export class DaemonPtyAdapter implements IPtyProvider { return result.exitedBeforeSpawnReply === true } - async attach(id: string): Promise { + async attach(id: string): Promise | void> { await this.ensureConnected() if (!this.canDelegateBackgroundToDaemon) { this.setPtyBackgrounded(id, false) @@ -924,6 +924,8 @@ export class DaemonPtyAdapter implements IPtyProvider { throw new SessionNotFoundError(id) } this.clearSessionAwaitingDaemonRecovery(id) + const providerSequence = providerSequenceFromCreateOrAttach(result) + return providerSequence ? { providerSequence } : undefined } hasPty(id: string): boolean { diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 8429495ed9f..5104268cbef 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -73,8 +73,8 @@ export class DaemonPtyRouter implements IPtyProvider { return this.current.supportsAgentSessionCreateOperations() } - async attach(id: string): Promise { - await this.adapterFor(id).attach(id) + async attach(id: string): ReturnType { + return await this.adapterFor(id).attach(id) } hasPty(id: string): boolean { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index bbe4032771a..db1a2834011 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -761,7 +761,7 @@ describe('registerPtyHandlers', () => { terminalHandle: 'term_recovered' } - function registerAgentClaimController(): { + function registerAgentClaimController(runtimeOverrides: Record = {}): { spawn: (args: Record) => Promise write: (ptyId: string, data: string) => boolean resize: (ptyId: string, cols: number, rows: number) => boolean @@ -783,7 +783,8 @@ describe('registerPtyHandlers', () => { }), createPreAllocatedTerminalHandle: vi.fn(() => 'term_recovered'), registerPreAllocatedHandleForPty: vi.fn(), - registerPty: vi.fn() + registerPty: vi.fn(), + ...runtimeOverrides } registerPtyHandlers(mainWindow as never, runtime as never) if (!controller) { @@ -928,6 +929,29 @@ describe('registerPtyHandlers', () => { } }) + it('synchronizes subscriber-driven attach to the daemon snapshot sequence', async () => { + const daemonPtyId = 'repo-1::/tmp/wt@@sequence-handoff' + const providerSequence = { value: 204, generation: 'continued' as const } + const localProvider = createAgentClaimProvider({}) + localProvider.attach.mockResolvedValueOnce({ providerSequence }) + setLocalPtyProvider(localProvider as never) + const getPtyOutputSequence = vi.fn(() => 0) + const synchronizePtyOutputSequenceFromProvider = vi.fn() + const controller = registerAgentClaimController({ + getPtyOutputSequence, + synchronizePtyOutputSequenceFromProvider + }) + + await expect(controller.attach(daemonPtyId)).resolves.toBe(true) + + expect(getPtyOutputSequence).toHaveBeenCalledWith(daemonPtyId) + expect(synchronizePtyOutputSequenceFromProvider).toHaveBeenCalledWith( + daemonPtyId, + providerSequence, + 0 + ) + }) + it('does not dispatch a runtime PTY spawn after its client disconnects', async () => { const provider = createAgentClaimProvider({}) setLocalPtyProvider(provider as never) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 7e4dfd621be..8fae8aa9862 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5320,7 +5320,15 @@ export function registerPtyHandlers( return false } try { - await provider.attach(ptyId) + const sequenceBeforeProviderAttach = runtime?.getPtyOutputSequence?.(ptyId) ?? 0 + const attachResult = await provider.attach(ptyId) + if (attachResult?.providerSequence) { + runtime?.synchronizePtyOutputSequenceFromProvider?.( + ptyId, + attachResult.providerSequence, + sequenceBeforeProviderAttach + ) + } return true } catch { return false diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 98ab3d9b69b..02b1d9acf1e 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -114,7 +114,7 @@ export type IPtyProvider = { providesAgentSessionOwnerListings?: (ptyId: string) => boolean /** Whether fresh structured creates can replay one spawn across a lost relay response. */ supportsAgentSessionCreateOperations?: (options?: PtyProbeOptions) => boolean | Promise - attach(id: string): Promise + attach(id: string): Promise | void> hasPty?: (id: string) => boolean /** Exact provider readback: false only when the provider answered that the PTY is absent. */ probePtyLiveness?: (id: string) => Promise diff --git a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts index 2d0eb5a9acd..1fbe42bddb2 100644 --- a/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts +++ b/tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts @@ -380,6 +380,7 @@ test('foregrounds a preserved daemon PTY after the paired host relaunches', asyn expect(readDaemonPid(session.userDataDir), 'daemon must survive the host relaunch').toBe( daemonPid ) + await openClientTab(client.page, worktreeId, target.webTabId) await expect .poll( () => @@ -391,15 +392,6 @@ test('foregrounds a preserved daemon PTY after the paired host relaunches', asyn ) .toBe(true) - target.handle = await findTerminalHandle(client, worktreeId, target.parentTabId) - const shown = await callRuntime<{ terminal: { ptyId: string | null } }>( - client.page, - client.environmentId, - 'terminal.show', - { terminal: target.handle } - ) - expect(shown.terminal.ptyId).toBe(target.ptyId) - await openClientTab(client.page, worktreeId, target.webTabId) await waitForPaneConnected(client.page, target.webTabId) await expect .poll( @@ -415,6 +407,7 @@ test('foregrounds a preserved daemon PTY after the paired host relaunches', asyn ) .toBe(true) await expectTerminalInteractive(client, target, 'x') + target.handle = await findTerminalHandle(client, worktreeId, target.parentTabId) const reconnectControl = await createHostTerminal(client, worktreeId, 'reconnect-control') terminals.push(reconnectControl)