refactor(tui): defer cross-process resume locking
This commit is contained in:
@@ -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, 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`.
|
||||
`/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, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. 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 replaces its process. 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.
|
||||
|
||||
@@ -156,6 +156,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. Deployments that can run concurrent hosts must coordinate ownership outside the TUI.
|
||||
- **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`.
|
||||
- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering.
|
||||
- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback.
|
||||
|
||||
@@ -79,7 +79,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
// Type import also declaration-merges the optional `sessionPersistence`
|
||||
// service onto `Context` so `ctx.get('sessionPersistence')` is typed.
|
||||
import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill'
|
||||
import type {
|
||||
FileDiff,
|
||||
@@ -349,7 +349,7 @@ export interface TuiRuntime {
|
||||
formatCwd?: (cwd: string | undefined) => string
|
||||
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
|
||||
now?(): number
|
||||
/** Host-owned safe process handoff; absent leaves `resumeCommand` as the fallback. */
|
||||
/** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */
|
||||
handoffResume?: TuiResumeHost['handoff']
|
||||
}
|
||||
|
||||
@@ -1283,7 +1283,6 @@ interface ResumeRoute {
|
||||
|
||||
interface ResumeCandidate {
|
||||
record: SessionRecord
|
||||
occupied: boolean
|
||||
title: string
|
||||
lastActivityAt: number
|
||||
lastTurn: string
|
||||
@@ -1324,7 +1323,6 @@ function summarizeResumeCandidate(
|
||||
snapshot: SessionLogSnapshot,
|
||||
currentId: SessionId,
|
||||
cwd: string | undefined,
|
||||
occupied: boolean,
|
||||
availableProviders: ReadonlySet<string>,
|
||||
): ResumeCandidate {
|
||||
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
|
||||
@@ -1332,14 +1330,13 @@ function summarizeResumeCandidate(
|
||||
const foldedGoal = foldGoal(snapshot.events).goal
|
||||
let disabledReason: string | undefined
|
||||
if (record.header.id === currentId) disabledReason = 'current session'
|
||||
else if (record.live || occupied) disabledReason = 'occupied by another live agent'
|
||||
else if (record.live) disabledReason = 'session is already live in this runtime'
|
||||
else if (record.header.cwd !== cwd) disabledReason = 'different workspace'
|
||||
else if (route !== undefined && !availableProviders.has(route.provider)) {
|
||||
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
|
||||
}
|
||||
return {
|
||||
record,
|
||||
occupied,
|
||||
title,
|
||||
lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
|
||||
lastTurn: resumeTurnLabel(snapshot),
|
||||
@@ -1422,7 +1419,7 @@ class ResumeDialog implements Component, Focusable {
|
||||
const selected = index === this.selectedIndex
|
||||
const status = [
|
||||
candidate.disabledReason === 'current session' ? 'current' : undefined,
|
||||
candidate.record.live || candidate.occupied ? 'live' : undefined,
|
||||
candidate.record.live ? 'live' : undefined,
|
||||
candidate.record.persisted ? 'persisted' : undefined,
|
||||
].filter((value): value is string => value !== undefined).join(' · ')
|
||||
const lead = `${selected ? '›' : ' '} ${displayText(candidate.title)}`
|
||||
@@ -1841,8 +1838,6 @@ 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 }
|
||||
@@ -1855,12 +1850,6 @@ 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).
|
||||
@@ -2444,8 +2433,6 @@ 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'))
|
||||
@@ -2825,9 +2812,6 @@ export function createTuiChat(
|
||||
providers: ReadonlySet<string>,
|
||||
): Promise<ResumeCandidate> => {
|
||||
try {
|
||||
const occupied = record.live || (record.persisted && persistence !== undefined
|
||||
? await persistence.isLive(record.header.id)
|
||||
: false)
|
||||
let snapshot: SessionLogSnapshot
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) {
|
||||
@@ -2845,13 +2829,11 @@ export function createTuiChat(
|
||||
snapshot,
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
occupied,
|
||||
providers,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
record,
|
||||
occupied: record.live,
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: record.header.createdAt,
|
||||
lastTurn: 'log unavailable',
|
||||
@@ -2895,14 +2877,8 @@ 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
|
||||
}
|
||||
/* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */
|
||||
if (disposed) 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
|
||||
@@ -2916,29 +2892,18 @@ export function createTuiChat(
|
||||
if (disposed) return
|
||||
ui.stop()
|
||||
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(reported)}`, 'error')
|
||||
appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error')
|
||||
} else {
|
||||
await overlay.close()
|
||||
resumeOverlay = undefined
|
||||
appendNotice(`Resume failed: ${errorChain(reported)}`, 'error')
|
||||
appendNotice(`Resume failed: ${errorChain(error)}`, 'error')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user