refactor(tui): defer cross-process resume locking

This commit is contained in:
Turtle
2026-07-24 16:07:49 +08:00
committed by ZiyaZhang
parent 54d986ed87
commit c440217fde
39 changed files with 133 additions and 1183 deletions

View File

@@ -10,7 +10,6 @@ import AgentRegistry, {
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -53,8 +52,6 @@ export interface TuiHarnessOptions {
sessionPersistence?: {
list(): Promise<SessionHeader[]>
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
isLive?(id: ReturnType<typeof SessionId>): Promise<boolean>
claimLive?(id: ReturnType<typeof SessionId>): Promise<SessionLiveLease>
}
handoffResume?: TuiRuntime['handoffResume']
/** Set false to exercise the optional session-query degradation path. */
@@ -140,12 +137,6 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
inspect: persistence.load === undefined
? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
: (id: ReturnType<typeof SessionId>) => persistence.load!(id),
claimLive: persistence.claimLive === undefined
? () => Promise.resolve({ release: () => Promise.resolve() })
: (id: ReturnType<typeof SessionId>) => persistence.claimLive!(id),
isLive: persistence.isLive === undefined
? () => Promise.resolve(false)
: (id: ReturnType<typeof SessionId>) => persistence.isLive!(id),
} as never)
}
if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) {

View File

@@ -396,7 +396,7 @@ describe('resume command and /resume', () => {
await dispose(result)
})
it('keeps persisted query records readable when live-lease inspection is unavailable', async () => {
it('keeps persisted query records readable without a persistence service', async () => {
const target = header('query-only-persisted', 10, '/workspace')
const result = await setup({
cwd: '/workspace',
@@ -503,21 +503,19 @@ describe('resume command and /resume', () => {
expect(result.terminal.stopped).toBeGreaterThan(0)
})
it('preflights route availability and occupied or corrupt sessions without losing the current TUI', async () => {
it('preflights route availability and corrupt sessions without losing the current TUI', async () => {
const missing = header('missing-route', 10, '/workspace')
const occupied = header('occupied', 20, '/workspace')
const corrupt = header('corrupt', 30, '/workspace')
const result = await setup({
cwd: '/workspace',
config: { resumeCommand: RESUME },
sessionPersistence: {
list: async () => [missing, occupied, corrupt],
isLive: async id => id === occupied.id,
list: async () => [missing, corrupt],
load: async (id) => {
if (id === corrupt.id) throw new Error('checksum mismatch')
return {
meta: id === missing.id ? missing : occupied,
events: resumeEvents(id === missing.id ? 'Missing adapter' : 'Busy session', id === missing.id ? 'absent-provider' : 'deepseek'),
meta: missing,
events: resumeEvents('Missing adapter', 'absent-provider'),
}
},
},
@@ -527,7 +525,6 @@ describe('resume command and /resume', () => {
await tick(); await tick()
expect(result.terminal.output).toContain('Missing adapter')
expect(result.terminal.output).toContain('absent-provider/model-1')
expect(result.terminal.output).toContain('Busy session')
expect(result.terminal.output).toContain('Unreadable session')
result.terminal.send('Missing adapter')
result.terminal.send('\r')
@@ -537,6 +534,38 @@ describe('resume command and /resume', () => {
await dispose(result)
})
it('keeps a session already live in this runtime visible but disabled', async () => {
const target = header('live-target', 10, '/workspace')
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
const result = await setup({
cwd: '/workspace',
handoffResume: handoff,
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: () => Promise.resolve([{
header: target,
live: true,
persisted: true,
}]),
readSession: () => Promise.resolve({
session: target,
events: resumeEvents('Live target'),
}),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Live target')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('session is already live in this runtime')
expect(handoff).not.toHaveBeenCalled()
await dispose(result)
})
it('falls back to assistant provenance and header creation time for sparse logs', async () => {
const assistantOnly = header('assistant-route', 20, '/workspace')
const empty = header('empty-log', 10, '/workspace')
@@ -562,8 +591,6 @@ describe('resume command and /resume', () => {
it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => {
const target = header('target-session', 10, '/workspace')
const releaseReservation = vi.fn(() => Promise.resolve())
const claimLive = vi.fn(async () => ({ release: releaseReservation }))
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => Promise.reject(new Error('test host retained process')))
const result = await setup({
cwd: '/workspace',
@@ -571,7 +598,6 @@ describe('resume command and /resume', () => {
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Target session') }),
claimLive,
},
})
result.terminal.send('/resume')
@@ -582,8 +608,6 @@ describe('resume command and /resume', () => {
await tick(); await tick()
expect(handoff).toHaveBeenCalledTimes(1)
expect(handoff).toHaveBeenCalledWith(target.id)
expect(claimLive).toHaveBeenCalledWith(target.id)
expect(releaseReservation).toHaveBeenCalledTimes(1)
expect(result.terminal.stopped).toBeGreaterThan(0)
expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
await dispose(result)
@@ -635,39 +659,46 @@ describe('resume command and /resume', () => {
await dispose(result)
})
it('keeps the current TUI when the target reservation loses the preflight race', async () => {
const target = header('reservation-race', 10, '/workspace')
it('does not flush or hand off when disposal begins during selected-session preflight', async () => {
const target = header('dispose-during-preflight', 10, '/workspace')
const secondListing = Promise.withResolvers<SessionRecord[]>()
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
const flush = vi.fn()
let listings = 0
const record: SessionRecord = { header: target, live: false, persisted: true }
const result = await setup({
cwd: '/workspace',
handoffResume: handoff,
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.on('session/flush', flush)
},
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Reservation race') }),
claimLive: () => Promise.reject(new Error('occupied after preflight')),
ctx.provide('sessionQuery', {
listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise,
readSession: () => Promise.resolve({
session: target,
events: resumeEvents('Dispose during preflight'),
}),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Reservation race')
await tick()
result.terminal.send('Dispose during preflight')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Resume failed: occupied after preflight')
await vi.waitFor(() => { expect(listings).toBe(2) })
await dispose(result)
secondListing.resolve([record])
await tick()
expect(flush).not.toHaveBeenCalled()
expect(handoff).not.toHaveBeenCalled()
expect(result.terminal.stopped).toBe(0)
await dispose(result)
})
it('refuses host handoff when a query backend has no persistence lease service', async () => {
it('hands off a validated session exposed by a query backend without a persistence service', async () => {
const target = header('query-without-persistence', 10, '/workspace')
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(
() => Promise.reject(new Error('test host retained process')),
)
const result = await setup({
cwd: '/workspace',
handoffResume: handoff,
@@ -692,42 +723,14 @@ describe('resume command and /resume', () => {
result.terminal.send('Query without persistence')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('session persistence is not mounted')
expect(handoff).not.toHaveBeenCalled()
expect(handoff).toHaveBeenCalledWith(target.id)
expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
await dispose(result)
})
it('releases a reservation that resolves after TUI disposal', async () => {
const target = header('late-reservation', 10, '/workspace')
const claiming = Promise.withResolvers<{ release(): Promise<void> }>()
const release = vi.fn(() => Promise.resolve())
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
const result = await setup({
cwd: '/workspace',
handoffResume: handoff,
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Late reservation') }),
claimLive: () => claiming.promise,
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Late reservation')
result.terminal.send('\r')
await tick()
await dispose(result)
claiming.resolve({ release })
await tick()
expect(release).toHaveBeenCalledTimes(1)
expect(handoff).not.toHaveBeenCalled()
})
it('does not hand off after disposal begins during the current-session flush', async () => {
const target = header('dispose-during-flush', 10, '/workspace')
const flushing = Promise.withResolvers<undefined>()
const release = vi.fn(() => Promise.resolve())
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
const result = await setup({
cwd: '/workspace',
@@ -739,7 +742,6 @@ describe('resume command and /resume', () => {
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }),
claimLive: async () => ({ release }),
},
})
result.terminal.send('/resume')
@@ -752,14 +754,12 @@ describe('resume command and /resume', () => {
await tick()
flushing.resolve(undefined)
await disposing
expect(release).toHaveBeenCalledTimes(1)
expect(handoff).not.toHaveBeenCalled()
})
it('does not hand off after disposal begins while terminal input drains', async () => {
const target = header('dispose-during-drain', 10, '/workspace')
const draining = Promise.withResolvers<undefined>()
const release = vi.fn(() => Promise.resolve())
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
const result = await setup({
cwd: '/workspace',
@@ -767,7 +767,6 @@ describe('resume command and /resume', () => {
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }),
claimLive: async () => ({ release }),
},
})
result.terminal.drainInput.mockImplementationOnce(() => draining.promise)
@@ -780,36 +779,33 @@ describe('resume command and /resume', () => {
await dispose(result)
draining.resolve(undefined)
await tick()
expect(release).toHaveBeenCalledTimes(1)
expect(handoff).not.toHaveBeenCalled()
})
it('reports a target reservation release failure after a recoverable host rejection', async () => {
const target = header('release-failure', 10, '/workspace')
let releases = 0
it('does not restart the terminal when a pending host rejects during disposal', async () => {
const target = header('host-rejects-during-disposal', 10, '/workspace')
const host = Promise.withResolvers<never>()
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => host.promise)
const result = await setup({
cwd: '/workspace',
handoffResume: () => Promise.reject(new Error('host rejected')),
handoffResume: handoff,
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Release failure') }),
claimLive: async () => ({
release: () => ++releases === 1
? Promise.reject(new Error('lock unavailable'))
: Promise.resolve(),
}),
load: async () => ({ meta: target, events: resumeEvents('Host disposal') }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Release failure')
result.terminal.send('Host disposal')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('target reservation release failed')
expect(result.terminal.output).toContain('release failed: lock')
await vi.waitFor(() => { expect(handoff).toHaveBeenCalled() })
const startsBeforeDispose = result.terminal.started
await dispose(result)
expect(releases).toBe(2)
host.reject(new Error('host rejected after disposal'))
await tick()
expect(result.terminal.started).toBe(startsBeforeDispose)
expect(result.terminal.output).not.toContain('host rejected after disposal')
})
it('rejects a candidate whose cwd changes between listing and preflight', async () => {