fix(tui): close resume handoff races

This commit is contained in:
NI0317
2026-07-24 12:58:53 +08:00
committed by ZiyaZhang
parent 2ae9f4fdf3
commit 54d986ed87
20 changed files with 375 additions and 76 deletions

View File

@@ -69,6 +69,6 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr
- **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration.
- **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required.
- **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface).
- **Lease scope is local-host advisory ownership** — PID liveness prevents two ordinary local Harness processes from resuming the same id, but it is not a distributed lease for shared network filesystems or hostile principals.
- **Lease scope is local-host advisory ownership** — PID plus same-process nonce checks prevent two ordinary local Harness processes from resuming the same id, but foreign PID reuse remains fail-closed and this is not a distributed lease for shared network filesystems or hostile principals.
- **A crash during stale-lease takeover fails closed** — if the reclaiming process itself crashes while holding the short-lived `.reclaim` guard, an operator must remove that guard after confirming no recovery is active.
- **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement.

View File

@@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import {
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
sessionLeaseProcessIsLive, shareSessionLiveLease,
sessionLeaseOwnerIsLive, shareSessionLiveLease,
type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner,
type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
@@ -314,7 +314,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
const current = await this.readLiveLease(path)
if (current !== undefined && current.pid === owner.pid && current.nonce === owner.nonce) break
if (current === undefined || sessionLeaseProcessIsLive(current.pid)) {
if (current === undefined || sessionLeaseOwnerIsLive(current, owner)) {
throw new Error(`session "${id}" is occupied by another live process`)
}
const reclaimPath = `${path}.reclaim`
@@ -335,7 +335,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
if (latest === undefined) {
if (await this.exists(path)) throw new Error(`session "${id}" has an unreadable live-process lease`)
} else if (latest.pid !== owner.pid || latest.nonce !== owner.nonce) {
if (sessionLeaseProcessIsLive(latest.pid)) {
if (sessionLeaseOwnerIsLive(latest, owner)) {
throw new Error(`session "${id}" is occupied by another live process`)
}
await rm(path, { force: true })
@@ -356,13 +356,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
}
/** Report one non-stale process lease and clean up a crashed owner's record. */
/** Report one non-stale process lease; acquisition reclaims a crashed owner's record. */
async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> {
const path = this.liveLeasePath(id)
const current = await this.readLiveLease(path)
if (current === undefined) return await this.exists(path)
if (current.pid === owner.pid && current.nonce === owner.nonce) return true
if (sessionLeaseProcessIsLive(current.pid)) return true
if (sessionLeaseOwnerIsLive(current, owner)) return true
return false
}

View File

@@ -249,6 +249,13 @@ describe('SessionPersistenceJsonl: cross-process live leases', () => {
const inheritedClaim = await ctx.sessionPersistence.claimLive(inherited)
await inheritedClaim.release()
const reusedPid = SessionId('reused-pid')
const reusedPidPath = join(liveDir, `${encodeSegment(reusedPid)}.lock`)
await writeFile(reusedPidPath, JSON.stringify({ pid: process.pid, nonce: 'prior-incarnation' }))
await expect(ctx.sessionPersistence.isLive(reusedPid)).resolves.toBe(false)
const reusedPidClaim = await ctx.sessionPersistence.claimLive(reusedPid)
await reusedPidClaim.release()
await expect(ctx.sessionPersistence.claimLive(SessionId('x'.repeat(300))))
.rejects.toThrow()

View File

@@ -57,3 +57,4 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p
- **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately.
- **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve).
- **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup).
- **Foreign PID reuse is fail-closed** — same-PID claimants compare the exec-stable nonce, while other processes conservatively retain a stale row until the reused PID exits or an operator verifies and removes it.

View File

@@ -15,7 +15,7 @@ import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import {
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
sessionLeaseProcessIsLive, shareSessionLiveLease,
sessionLeaseOwnerIsLive, shareSessionLiveLease,
type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner,
type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
@@ -295,7 +295,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
const current = this.liveLeaseFor(id)
if (current !== undefined
&& (current.pid !== owner.pid || current.nonce !== owner.nonce)) {
if (sessionLeaseProcessIsLive(current.pid)) {
if (sessionLeaseOwnerIsLive(current, owner)) {
throw new Error(`session "${id}" is occupied by another live process`)
}
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id)
@@ -323,7 +323,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
const current = this.liveLeaseFor(id)
if (current === undefined) return false
if ((current.pid === owner.pid && current.nonce === owner.nonce)
|| sessionLeaseProcessIsLive(current.pid)) return true
|| sessionLeaseOwnerIsLive(current, owner)) return true
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?')
.run(id, current.pid, current.nonce)
return false

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { existsSync } from 'node:fs'
import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
@@ -13,7 +13,10 @@ import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../sessi
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
afterEach(async () => {
vi.restoreAllMocks()
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
})
async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
try {
@@ -465,9 +468,16 @@ describe('SessionPersistenceSqlite: edge cases', () => {
await b.ctx.sessionPersistence.list()
const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite
const owner = sessionLiveOwner()
const occupiedPid = process.pid + 1
const originalKill = process.kill.bind(process)
vi.spyOn(process, 'kill').mockImplementation((pid, signal) => {
if (pid === occupiedPid) return true
return originalKill(pid, signal)
})
const db = openDatabase(path, 'wal')
const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)')
insert.run('occupied-lease', process.pid, 'another-owner')
insert.run('occupied-lease', occupiedPid, 'another-owner')
insert.run('reused-pid', process.pid, 'prior-incarnation')
insert.run('stale-claim', 2_147_483_647, 'dead-owner')
insert.run('stale-inspect', 2_147_483_647, 'dead-owner')
insert.run('owned-inspect', owner.pid, owner.nonce)
@@ -475,11 +485,13 @@ describe('SessionPersistenceSqlite: edge cases', () => {
await expect(concrete.acquireLive(SessionId('occupied-lease'), owner))
.rejects.toThrow('occupied by another live process')
const reused = await concrete.acquireLive(SessionId('reused-pid'), owner)
const claim = await concrete.acquireLive(SessionId('stale-claim'), owner)
expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true)
expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false)
expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false)
await claim()
await reused()
await b.dispose()
const memory = new Context()

View File

@@ -85,3 +85,4 @@ Persistence does not mutate live request prefixes. A resumed loop can reuse prov
- **No deletion or retention surface** — pruning stored sessions is out-of-band backend maintenance.
- **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale.
- **Repair-time synthetic closers are the only crash story** — a backend must synthesize `tool/result`/`step/end`/`turn/end` closers on load; there is no partial-turn resume that continues an interrupted turn instead of closing it.
- **Foreign PID reuse is fail-closed** — a claimant with the reused PID detects its different nonce and reclaims safely, but another process cannot observe that foreign process's private nonce and treats the PID as live until it exits or an operator verifies and removes the stale lease.

View File

@@ -13,7 +13,12 @@ import type { SessionLiveLease } from './lease.ts'
// Re-export the metadata vocabulary so consumers import it from the seam.
export type { SessionHeader } from '@deepseek-ai/dsh-session'
export { SessionPersistenceRevision } from './revision.ts'
export { sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease } from './lease.ts'
export {
sessionLeaseOwnerIsLive,
sessionLeaseProcessIsLive,
sessionLiveOwner,
shareSessionLiveLease,
} from './lease.ts'
export type { SessionLiveLease, SessionLiveOwner } from './lease.ts'
/** Lightweight immutable source identity returned without loading a full log. */

View File

@@ -8,7 +8,7 @@ const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER'
export interface SessionLiveOwner {
/** Operating-system process id; retained across an `execve` handoff. */
readonly pid: number
/** Per-process-start nonce that distinguishes PID reuse. */
/** Exec-stable process-start nonce used when the observer has the same PID. */
readonly nonce: string
}
@@ -42,9 +42,26 @@ export function sessionLeaseProcessIsLive(pid: number): boolean {
}
}
/**
* Whether a recorded owner still names this process incarnation or another live PID.
* A same-PID nonce mismatch proves reuse and is stale; an unrelated live PID is
* fail-closed because its private nonce is not observable across processes.
* @param recorded - owner stored in the backend lease.
* @param observer - identity of the process inspecting or claiming the lease.
* @returns whether the recorded owner must still be treated as live.
*/
export function sessionLeaseOwnerIsLive(
recorded: SessionLiveOwner,
observer: SessionLiveOwner,
): boolean {
if (recorded.pid === observer.pid) return recorded.nonce === observer.nonce
return sessionLeaseProcessIsLive(recorded.pid)
}
interface SharedLeaseEntry {
refs: number
readonly acquired: Promise<() => Promise<void>>
finalizing?: Promise<void>
}
const sharedLeases = new Map<string, SharedLeaseEntry>()
@@ -59,40 +76,48 @@ export async function shareSessionLiveLease(
key: string,
acquire: () => Promise<() => Promise<void>>,
): Promise<() => Promise<void>> {
let entry = sharedLeases.get(key)
if (entry === undefined) {
entry = { refs: 0, acquired: acquire() }
sharedLeases.set(key, entry)
void entry.acquired.catch(() => {
/* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */
if (sharedLeases.get(key) === entry) sharedLeases.delete(key)
})
}
entry.refs += 1
try {
await entry.acquired
} catch (error) {
entry.refs -= 1
throw error
}
let releaseTask: Promise<void> | undefined
return () => {
if (releaseTask !== undefined) return releaseTask
const task = (async () => {
for (;;) {
let entry = sharedLeases.get(key)
if (entry?.finalizing !== undefined) {
await entry.finalizing
continue
}
if (entry === undefined) {
entry = { refs: 0, acquired: acquire() }
sharedLeases.set(key, entry)
void entry.acquired.catch(() => {
/* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */
if (sharedLeases.get(key) === entry) sharedLeases.delete(key)
})
}
entry.refs += 1
try {
await entry.acquired
} catch (error) {
entry.refs -= 1
if (entry.refs > 0 || sharedLeases.get(key) !== entry) return
const release = await entry.acquired
await release()
/* v8 ignore next -- the entry remains installed until this exact final release succeeds */
if (sharedLeases.get(key) === entry) sharedLeases.delete(key)
})()
const wrapped = task.catch((error: unknown) => {
entry.refs += 1
/* v8 ignore next -- this closure is the sole writer of its releaseTask until settlement */
if (releaseTask === wrapped) releaseTask = undefined
throw error
})
releaseTask = wrapped
return wrapped
}
let releaseTask: Promise<void> | undefined
return () => {
if (releaseTask !== undefined) return releaseTask
const task = (async () => {
entry.refs -= 1
if (entry.refs > 0 || sharedLeases.get(key) !== entry) return
const release = await entry.acquired
await release()
/* v8 ignore next -- claims wait for finalization before they can replace this exact entry */
if (sharedLeases.get(key) === entry) sharedLeases.delete(key)
})()
const wrapped = task.catch((error: unknown) => {
entry.refs += 1
/* v8 ignore next -- this closure is the sole writer of its release state until settlement */
if (entry.finalizing === wrapped) delete entry.finalizing
releaseTask = undefined
throw error
})
if (entry.refs === 0 && sharedLeases.get(key) === entry) entry.finalizing = wrapped
releaseTask = wrapped
return wrapped
}
}
}

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
import {
sessionLeaseOwnerIsLive,
sessionLeaseProcessIsLive,
sessionLiveOwner,
shareSessionLiveLease,
@@ -21,10 +22,14 @@ describe('process live-session lease helpers', () => {
expect(first.pid).toBe(process.pid)
expect(typeof first.nonce).toBe('string')
expect(sessionLiveOwner()).toEqual(first)
expect(sessionLeaseOwnerIsLive(first, first)).toBe(true)
expect(sessionLeaseOwnerIsLive({ ...first, nonce: 'reused-pid' }, first)).toBe(false)
expect(sessionLeaseProcessIsLive(process.pid)).toBe(true)
const missing = Object.assign(new Error('missing'), { code: 'ESRCH' })
vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing })
expect(sessionLeaseOwnerIsLive({ pid: 999_999, nonce: 'gone' }, first)).toBe(false)
vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing })
expect(sessionLeaseProcessIsLive(999_999)).toBe(false)
const denied = Object.assign(new Error('denied'), { code: 'EPERM' })
vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw denied })
@@ -59,4 +64,29 @@ describe('process live-session lease helpers', () => {
await expect(release()).resolves.toBeUndefined()
expect(releases).toBe(2)
})
it('waits for a final physical release before reacquiring the same key', async () => {
const key = `finalizing-${randomUUID()}`
const releaseGate = Promise.withResolvers<undefined>()
const firstPhysicalRelease = vi.fn(() => releaseGate.promise)
const secondPhysicalRelease = vi.fn(() => Promise.resolve())
const releases: Array<() => Promise<void>> = [firstPhysicalRelease, secondPhysicalRelease]
let acquisitions = 0
const acquire = vi.fn<() => Promise<() => Promise<void>>>((): Promise<() => Promise<void>> => {
const release = releases[acquisitions++]
if (release === undefined) throw new Error('unexpected physical acquisition')
return Promise.resolve(release)
})
const first = await shareSessionLiveLease(key, acquire)
const finalizing = first()
const reacquiring = shareSessionLiveLease(key, acquire)
await Promise.resolve()
expect(acquire).toHaveBeenCalledTimes(1)
releaseGate.resolve(undefined)
await finalizing
const second = await reacquiring
expect(acquire).toHaveBeenCalledTimes(2)
await second()
expect(secondPhysicalRelease).toHaveBeenCalledTimes(1)
})
})

View File

@@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, flushes it, stops the terminal UI, and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, and claims the target live lease before flushing the current session; a lost claim race or later recoverable failure leaves the current TUI running and releases any acquired reservation. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process while retaining the reservation, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
`resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text.

View File

@@ -53,9 +53,6 @@
"@deepseek-ai/dsh-session-query": {
"optional": true
},
"@deepseek-ai/dsh-goal": {
"optional": true
},
"@deepseek-ai/dsh-skill": {
"optional": true
}

View File

@@ -77,9 +77,9 @@ import type {
SessionLogSnapshot,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
// Side-effect type import: declaration-merges the optional `sessionPersistence`
// Type import also declaration-merges the optional `sessionPersistence`
// service onto `Context` so `ctx.get('sessionPersistence')` is typed.
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence'
import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill'
import type {
FileDiff,
@@ -1841,6 +1841,8 @@ export function createTuiChat(
let modelOverlay: TuiOverlaySession | undefined
let resumeOverlay: TuiOverlaySession | undefined
let resumeInFlight = false
let resumeReservation: SessionLiveLease | undefined
let resumeReservationCommitted = false
let resumeScan = 0
let tuiServiceFiber: Fiber | undefined
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
@@ -1853,6 +1855,12 @@ export function createTuiChat(
const now = (): number => runtime.now?.() ?? Date.now()
const agentStatus = (): AgentStatus => agent.status
const isDisposed = (): boolean => disposed
const releaseResumeReservation = async (): Promise<void> => {
const reservation = resumeReservation
if (reservation === undefined) return
await reservation.release()
resumeReservation = undefined
}
// A configured subtitle renders as a banner line; when absent, the banner has
// no subtitle. The banner itself sweeps in on start (see startBannerReveal).
@@ -2436,6 +2444,8 @@ export function createTuiChat(
shuttingDown ??= (async () => {
disposed = true
overlayManager.beginShutdown()
/* v8 ignore else -- the committed branch is the non-returning exec handoff covered by the keyless PTY test */
if (!resumeReservationCommitted) await releaseResumeReservation()
contextResolution = undefined
clearStatus()
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
@@ -2871,6 +2881,7 @@ export function createTuiChat(
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
if (resumeInFlight) return
resumeInFlight = true
let terminalReleased = false
try {
const checked = await preflightResume(candidate.record.header.id)
const hostHandoff = runtime.handoffResume
@@ -2884,30 +2895,52 @@ export function createTuiChat(
: `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning')
return
}
if (persistence === undefined) {
throw new Error('Resume is unavailable: session persistence is not mounted.')
}
resumeReservation = await persistence.claimLive(checked.record.header.id)
if (disposed) {
await releaseResumeReservation()
return
}
await ctx.sessions.flush(agent.session)
// Disposal can run while the flush promise is pending; TypeScript does not model that reentry.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (disposed) return
if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`)
await overlay.close()
resumeOverlay = undefined
await runtime.terminal.drainInput(100, 20)
// Disposal can run while terminal draining is pending; TypeScript does not model that reentry.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (disposed) return
ui.stop()
try {
await hostHandoff(checked.record.header.id)
throw new Error('resume host returned without replacing the process')
} catch (error: unknown) {
/* v8 ignore next -- a committed host disposes this TUI and never returns; pre-commit rejection keeps it live */
if (!disposed) {
terminalReleased = true
resumeReservationCommitted = true
await hostHandoff(checked.record.header.id)
throw new Error('resume host returned without replacing the process')
} catch (error: unknown) {
/* v8 ignore next -- a committed host disposes this TUI and never returns; recoverable rejection keeps it live */
if (!disposed) {
resumeReservationCommitted = false
let reported = error
try {
await releaseResumeReservation()
} catch (releaseError: unknown) {
reported = new Error(
`${errorChain(error)}; target reservation release failed: ${errorChain(releaseError)}`,
)
}
if (terminalReleased) {
ui.start()
ui.setFocus(editor)
appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error')
appendNotice(`Resume handoff failed: ${errorChain(reported)}`, 'error')
} else {
await overlay.close()
resumeOverlay = undefined
appendNotice(`Resume failed: ${errorChain(reported)}`, 'error')
}
}
} catch (error: unknown) {
/* v8 ignore next -- disposal settles the overlay and suppresses late preflight diagnostics */
if (!disposed) {
await overlay.close()
resumeOverlay = undefined
appendNotice(`Resume failed: ${errorChain(error)}`, 'error')
}
} finally {
resumeInFlight = false
}

View File

@@ -10,6 +10,7 @@ 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,6 +54,7 @@ export interface TuiHarnessOptions {
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. */
@@ -138,7 +140,9 @@ 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: () => Promise.resolve({ release: () => Promise.resolve() }),
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),

View File

@@ -562,6 +562,8 @@ 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',
@@ -569,6 +571,7 @@ describe('resume command and /resume', () => {
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Target session') }),
claimLive,
},
})
result.terminal.send('/resume')
@@ -579,6 +582,8 @@ 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)
@@ -630,6 +635,183 @@ 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')
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
const flush = vi.fn()
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')),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Reservation race')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Resume failed: occupied after preflight')
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 () => {
const target = header('query-without-persistence', 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: false,
persisted: true,
}]),
readSession: () => Promise.resolve({
session: target,
events: resumeEvents('Query without persistence'),
}),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
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()
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',
handoffResume: handoff,
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.on('session/flush', () => flushing.promise)
},
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }),
claimLive: async () => ({ release }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Dispose during flush')
result.terminal.send('\r')
await tick()
const disposing = dispose(result)
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',
handoffResume: handoff,
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }),
claimLive: async () => ({ release }),
},
})
result.terminal.drainInput.mockImplementationOnce(() => draining.promise)
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Dispose during drain')
result.terminal.send('\r')
await vi.waitFor(() => { expect(result.terminal.drainInput).toHaveBeenCalled() })
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
const result = await setup({
cwd: '/workspace',
handoffResume: () => Promise.reject(new Error('host rejected')),
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(),
}),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Release failure')
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 dispose(result)
expect(releases).toBe(2)
})
it('rejects a candidate whose cwd changes between listing and preflight', async () => {
const target = header('moving-workspace', 10, '/workspace')
let listings = 0