fix(tui): close resume handoff races
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -53,9 +53,6 @@
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-goal": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-skill": {
|
||||
"optional": true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user