feat(tui): add safe session resume flow

This commit is contained in:
NI0317
2026-07-24 12:31:26 +08:00
committed by ZiyaZhang
parent 65d29da8a1
commit 2ae9f4fdf3
57 changed files with 2312 additions and 192 deletions
+29 -2
View File
@@ -25,7 +25,7 @@ import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { SessionLiveLease, SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import {
bindReactLoopAgentContext,
prepareReactLoopAgent,
@@ -114,6 +114,7 @@ class AgentCreationTransaction {
private scope: Scope | undefined
private session: Session | undefined
private lifecycleDispose: (() => Promise<void> | void) | undefined
private liveLease: SessionLiveLease | undefined
private detachSession: (() => void) | undefined
private detachAgent: (() => void) | undefined
private publishing = false
@@ -186,6 +187,12 @@ class AgentCreationTransaction {
])
}
/** Retain a pre-load persistence lease until this transaction fully tears down. */
holdLiveLease(lease: SessionLiveLease): void {
this.assertActive()
this.liveLease = lease
}
/** Construct the driver and scope, then install their complete ordered lifecycle. */
prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent {
this.assertActive()
@@ -219,6 +226,11 @@ class AgentCreationTransaction {
// First yielded, disposed last.
yield () => { this.finish() }
yield scope.rawDispose
yield async () => {
const lease = this.liveLease
this.liveLease = undefined
await lease?.release()
}
yield () => {
this.detachSession?.()
this.detachSession = undefined
@@ -315,7 +327,13 @@ class AgentCreationTransaction {
try {
await this.scope?.dispose()
} finally {
this.finish()
try {
const lease = this.liveLease
this.liveLease = undefined
await lease?.release()
} finally {
this.finish()
}
}
}
})())
@@ -607,6 +625,15 @@ export class AgentLoop extends Service implements AgentFactory {
options.signal,
)
try {
const claiming = persistence.claimLive(options.resumeSessionId)
let lease: SessionLiveLease
try {
lease = await transaction.waitFor(claiming)
} catch (error) {
void claiming.then(claim => claim.release(), () => {})
throw error
}
transaction.holdLiveLease(lease)
const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId))
transaction.assertActive()
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
@@ -391,6 +391,51 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx.fiber.dispose()
})
it('owner unload during live-lease acquisition releases a late claim', async () => {
const sessionId = SessionId('resume-claim-owner-unload')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const claiming = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.claimLive>>>()
const claimStarted = Promise.withResolvers<undefined>()
const originalClaim = ctx.sessionPersistence.claimLive.bind(ctx.sessionPersistence)
ctx.sessionPersistence.claimLive = (id) => {
expect(id).toBe(sessionId)
claimStarted.resolve(undefined)
return claiming.promise
}
let resuming!: ReturnType<typeof ctx.agents.resume>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
resuming = inner.agents.resume({ resumeSessionId: sessionId })
}, { inject: ['agents'] }))
await claimStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
await promptly(owner.dispose())
await rejection
let releases = 0
claiming.resolve({ release: () => { releases += 1; return Promise.resolve() } })
await Promise.resolve()
await Promise.resolve()
expect(releases).toBe(1)
ctx.sessionPersistence.claimLive = originalClaim
await ctx.fiber.dispose()
})
it('propagates a rejected live-lease claim without loading or publishing', async () => {
const sessionId = SessionId('resume-claim-rejected')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
let loads = 0
ctx.sessionPersistence.claimLive = () => Promise.reject(new Error('occupied elsewhere'))
ctx.sessionPersistence.load = () => { loads += 1; return Promise.reject(new Error('must not load')) }
await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
.rejects.toThrow('occupied elsewhere')
expect(loads).toBe(0)
expect(ctx.agents.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
const sessionId = SessionId('resume-load-factory-unload')
const root = await persistSession(sessionId)