fix(terminal): preserve daemon sequence on subscriber attach

This commit is contained in:
Jinwoo-H
2026-08-08 04:20:55 -04:00
parent f858c5a13a
commit 595553eb5e
7 changed files with 59 additions and 18 deletions
@@ -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')
+5 -3
View File
@@ -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<void> {
async attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence'> | 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 {
+2 -2
View File
@@ -73,8 +73,8 @@ export class DaemonPtyRouter implements IPtyProvider {
return this.current.supportsAgentSessionCreateOperations()
}
async attach(id: string): Promise<void> {
await this.adapterFor(id).attach(id)
async attach(id: string): ReturnType<IPtyProvider['attach']> {
return await this.adapterFor(id).attach(id)
}
hasPty(id: string): boolean {
+26 -2
View File
@@ -761,7 +761,7 @@ describe('registerPtyHandlers', () => {
terminalHandle: 'term_recovered'
}
function registerAgentClaimController(): {
function registerAgentClaimController(runtimeOverrides: Record<string, unknown> = {}): {
spawn: (args: Record<string, unknown>) => Promise<unknown>
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)
+9 -1
View File
@@ -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
+1 -1
View File
@@ -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<boolean>
attach(id: string): Promise<void>
attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence'> | void>
hasPty?: (id: string) => boolean
/** Exact provider readback: false only when the provider answered that the PTY is absent. */
probePtyLiveness?: (id: string) => Promise<boolean | null>
@@ -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)