feat(tui): add safe session resume flow
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user