mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
fix(terminal): preserve daemon sequence on subscriber attach
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user