feat(tui): add safe session resume flow

This commit is contained in:
NI0317
2026-07-24 12:31:26 +08:00
committed by ZiyaZhang
parent 65d29da8a1
commit 2ae9f4fdf3
57 changed files with 2312 additions and 192 deletions

View File

@@ -480,6 +480,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>',
jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */',
},
{
signature: 'claimLive(id: SessionId): Promise<SessionLiveLease>',
jsDoc: '/**\n * Atomically acquire this process\'s live ownership of a session id.\n * Reentrant claims share one backend lease. First-party backends override\n * this process-local fallback to reject another live process and reclaim a\n * dead owner.\n * @param id - session identity that is about to become live.\n * @returns a single-release reference owned by the caller.\n */',
},
{
signature: 'isLive(id: SessionId): Promise<boolean>',
jsDoc: '/**\n * Check whether any process currently owns a live lease for this session.\n * The base implementation reports only claims on this service instance.\n * @param id - persisted or prospective session identity.\n * @returns true while a non-stale lease exists, including this process\'s lease.\n */',
},
],
},
{
@@ -498,6 +506,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'listSessions(): Promise<SessionRecord[]>',
jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */',
},
{
signature: 'async readSession(sessionId: SessionId): Promise<SessionLogSnapshot>',
jsDoc: '/**\n * Read and replay-validate one complete logical session log without making it live.\n * @param sessionId - live or persisted session id to read.\n * @returns cloned header and complete raw event log from one observation.\n * @throws when persistence, header compatibility, or replay validation fails.\n */',
},
{
signature: 'async filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]>',
jsDoc: '/**\n * Filter the complete logical corpus with provider-independent predicates.\n * @param filters - ANDed session metadata and availability clauses.\n * @returns matching cloned records in deterministic newest-first order.\n */',
@@ -1805,10 +1817,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionLineageTrace',
declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});',
},
{
name: 'SessionLiveLease',
declaration: 'export interface SessionLiveLease {\n release(): Promise<void>;\n}',
},
{
name: 'SessionLocation',
declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}',
},
{
name: 'SessionLogSnapshot',
declaration: 'export interface SessionLogSnapshot {\n session: SessionHeader;\n events: SessionEvent[];\n}',
},
{
name: 'SessionPersistenceRevision',
declaration: 'export type SessionPersistenceRevision = Branded<\'SessionPersistenceRevision\'>;',

View File

@@ -25,7 +25,7 @@ import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { SessionLiveLease, SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import {
bindReactLoopAgentContext,
prepareReactLoopAgent,
@@ -114,6 +114,7 @@ class AgentCreationTransaction {
private scope: Scope | undefined
private session: Session | undefined
private lifecycleDispose: (() => Promise<void> | void) | undefined
private liveLease: SessionLiveLease | undefined
private detachSession: (() => void) | undefined
private detachAgent: (() => void) | undefined
private publishing = false
@@ -186,6 +187,12 @@ class AgentCreationTransaction {
])
}
/** Retain a pre-load persistence lease until this transaction fully tears down. */
holdLiveLease(lease: SessionLiveLease): void {
this.assertActive()
this.liveLease = lease
}
/** Construct the driver and scope, then install their complete ordered lifecycle. */
prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent {
this.assertActive()
@@ -219,6 +226,11 @@ class AgentCreationTransaction {
// First yielded, disposed last.
yield () => { this.finish() }
yield scope.rawDispose
yield async () => {
const lease = this.liveLease
this.liveLease = undefined
await lease?.release()
}
yield () => {
this.detachSession?.()
this.detachSession = undefined
@@ -315,7 +327,13 @@ class AgentCreationTransaction {
try {
await this.scope?.dispose()
} finally {
this.finish()
try {
const lease = this.liveLease
this.liveLease = undefined
await lease?.release()
} finally {
this.finish()
}
}
}
})())
@@ -607,6 +625,15 @@ export class AgentLoop extends Service implements AgentFactory {
options.signal,
)
try {
const claiming = persistence.claimLive(options.resumeSessionId)
let lease: SessionLiveLease
try {
lease = await transaction.waitFor(claiming)
} catch (error) {
void claiming.then(claim => claim.release(), () => {})
throw error
}
transaction.holdLiveLease(lease)
const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId))
transaction.assertActive()
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {

View File

@@ -391,6 +391,51 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx.fiber.dispose()
})
it('owner unload during live-lease acquisition releases a late claim', async () => {
const sessionId = SessionId('resume-claim-owner-unload')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const claiming = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.claimLive>>>()
const claimStarted = Promise.withResolvers<undefined>()
const originalClaim = ctx.sessionPersistence.claimLive.bind(ctx.sessionPersistence)
ctx.sessionPersistence.claimLive = (id) => {
expect(id).toBe(sessionId)
claimStarted.resolve(undefined)
return claiming.promise
}
let resuming!: ReturnType<typeof ctx.agents.resume>
const owner = await ctx.plugin(Object.assign((inner: Context) => {
resuming = inner.agents.resume({ resumeSessionId: sessionId })
}, { inject: ['agents'] }))
await claimStarted.promise
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
await promptly(owner.dispose())
await rejection
let releases = 0
claiming.resolve({ release: () => { releases += 1; return Promise.resolve() } })
await Promise.resolve()
await Promise.resolve()
expect(releases).toBe(1)
ctx.sessionPersistence.claimLive = originalClaim
await ctx.fiber.dispose()
})
it('propagates a rejected live-lease claim without loading or publishing', async () => {
const sessionId = SessionId('resume-claim-rejected')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
let loads = 0
ctx.sessionPersistence.claimLive = () => Promise.reject(new Error('occupied elsewhere'))
ctx.sessionPersistence.load = () => { loads += 1; return Promise.reject(new Error('must not load')) }
await expect(ctx.agents.resume({ resumeSessionId: sessionId }))
.rejects.toThrow('occupied elsewhere')
expect(loads).toBe(0)
expect(ctx.agents.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
const sessionId = SessionId('resume-load-factory-unload')
const root = await persistSession(sessionId)

View File

@@ -41,10 +41,11 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
| `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` |
| `welcome` | `ready.` | TUI subtitle |
| `resumeCommand` | — | Exit and no-host fallback command template; the selector itself uses session query and host handoff |
| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height |
| `resumeSessionId` | — | Exact persisted session to resume |
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal.
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for safe in-place process handoff.
## The bin

View File

@@ -6,6 +6,9 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
```
<root>/
.live/
<encoded-id>.lock # PID + nonce cross-process live lease
<encoded-id>.lock.reclaim # ephemeral stale-owner takeover guard
cwd-<sha256(cwd)[:12]>/ # per-project bucket (or _no-cwd/ when no cwd)
<encoded-id>.jsonl.zstd # default: checksummed header frame + append frames
<encoded-id>.jsonl # only with compression: 'none'
@@ -43,7 +46,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
## Write path
The plugin copies frozen session events into one controller per live session and starts an eager drain. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. The owning backend instance serializes operations for one session; disposal drains every retained controller before teardown.
The plugin copies frozen session events into one controller per live session and starts an eager drain. Before a session can flush or resume, the coordinator claims an exclusive `.live/<encoded-id>.lock` containing the process PID and an exec-stable nonce; another live process is rejected, while a dead owner is reclaimed under the separate `.reclaim` guard. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Disposal drains every retained controller before releasing its lease.
## Model Experience
@@ -66,5 +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).
- **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the POSIX no-overwrite hard link or Windows write-through rename without replacement.
- **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.
- **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,8 +14,9 @@ import { dirname, join, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import {
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
type StoredPrefix,
sessionLeaseProcessIsLive, shareSessionLiveLease,
type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner,
type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import {
@@ -64,6 +65,11 @@ interface JsonlTornMarker {
recoveredEvents: SessionEvent[]
}
interface JsonlLiveLeaseRecord {
pid: number
nonce: string
}
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
@@ -135,6 +141,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
return this.coordinator.inspect(id)
}
override claimLive(id: SessionId): Promise<SessionLiveLease> {
return this.coordinator.claimLive(id)
}
override isLive(id: SessionId): Promise<boolean> {
return this.coordinator.isLive(id)
}
// One method serves both public `list` and the backend hook; delegating it to
// the coordinator would call this hook recursively.
@@ -274,6 +288,110 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
return snapshots
}
/** Atomically publish one process lease, reclaiming a crashed owner's record. */
async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> {
const path = this.liveLeasePath(id)
return shareSessionLiveLease(`jsonl:${path}`, () => this.acquireLiveFile(path, id, owner))
}
private async acquireLiveFile(
path: string,
id: SessionId,
owner: SessionLiveOwner,
): Promise<() => Promise<void>> {
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
for (;;) {
try {
const handle = await open(path, 'wx', 0o600)
try {
await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8')
await handle.sync()
} finally {
await handle.close()
}
break
} catch (error) {
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)) {
throw new Error(`session "${id}" is occupied by another live process`)
}
const reclaimPath = `${path}.reclaim`
let reclaim: Awaited<ReturnType<typeof open>>
try {
reclaim = await open(reclaimPath, 'wx', 0o600)
} catch (reclaimError) {
/* v8 ignore else -- non-contention filesystem failures are propagated verbatim and are not portable to induce */
if ((reclaimError as NodeJS.ErrnoException).code === 'EEXIST') {
throw new Error(`session "${id}" live-lease reclamation is already in progress`)
}
/* v8 ignore next -- non-contention filesystem failures are propagated verbatim and are not portable to induce */
throw reclaimError
}
try {
/* v8 ignore start -- cross-process revalidation is covered by the two-process race test */
const latest = await this.readLiveLease(path)
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)) {
throw new Error(`session "${id}" is occupied by another live process`)
}
await rm(path, { force: true })
}
/* v8 ignore stop */
} finally {
try {
await reclaim.close()
} finally {
await rm(reclaimPath, { force: true })
}
}
}
}
return async () => {
const current = await this.readLiveLease(path)
if (current?.pid === owner.pid && current.nonce === owner.nonce) await rm(path, { force: true })
}
}
/** Report one non-stale process lease and clean up 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
return false
}
private liveLeasePath(id: SessionId): string {
return join(this.root, '.live', `${encodeSegment(id)}.lock`)
}
private async readLiveLease(path: string): Promise<JsonlLiveLeaseRecord | undefined> {
let text: string
try {
text = await readFile(path, 'utf8')
} catch (error) {
if (isENOENT(error)) return undefined
throw error
}
let value: unknown
try {
value = JSON.parse(text)
} catch {
return undefined
}
if (typeof value !== 'object' || value === null
|| !Number.isSafeInteger((value as { pid?: unknown }).pid)
|| (value as { pid: number }).pid <= 0
|| typeof (value as { nonce?: unknown }).nonce !== 'string'
|| (value as { nonce: string }).nonce.length === 0) return undefined
return value as JsonlLiveLeaseRecord
}
private async listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>> {
await this.ensureRootEncoding()
const artifacts: Array<{ header: SessionHeader; path: string }> = []

View File

@@ -0,0 +1,16 @@
/** Child process that holds one JSONL live-session lease until it is killed. */
import { writeFile } from 'node:fs/promises'
import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
const [root, marker] = process.argv.slice(2)
if (root === undefined || marker === undefined) throw new Error('usage: live-lease-child.ts <root> <marker>')
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx.sessionPersistence.claimLive(SessionId('leased-session'))
await writeFile(marker, 'held')
await new Promise<never>(() => { setInterval(() => {}, 60_000) })

View File

@@ -0,0 +1,33 @@
/** Child process competing to reclaim one stale JSONL live-session lease. */
import { access, writeFile } from 'node:fs/promises'
import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
const [root, gate, marker, rawId] = process.argv.slice(2)
if (root === undefined || gate === undefined || marker === undefined || rawId === undefined) {
throw new Error('usage: live-lease-race-child.ts <root> <gate> <marker> <session-id>')
}
for (;;) {
try {
await access(gate)
break
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
await new Promise(resolve => setTimeout(resolve, 5))
}
}
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
try {
await ctx.sessionPersistence.claimLive(SessionId(rawId))
await writeFile(marker, 'claimed')
await new Promise<never>(() => { setInterval(() => {}, 60_000) })
} catch (error) {
await writeFile(marker, `rejected:${error instanceof Error ? error.message : String(error)}`)
await ctx.fiber.dispose()
}

View File

@@ -1,17 +1,24 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { spawn } from 'node:child_process'
import { Context } from 'cordis'
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import { access, appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { isAbsolute, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence'
import { encodeSegment, eventLines, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts'
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
let root: string
const dirs: string[] = []
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const leaseChild = fileURLToPath(new URL('./fixtures/live-lease-child.ts', import.meta.url))
const leaseRaceChild = fileURLToPath(new URL('./fixtures/live-lease-race-child.ts', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
@@ -142,6 +149,179 @@ describe('SessionPersistenceJsonl: format helpers', () => {
})
})
describe('SessionPersistenceJsonl: cross-process live leases', () => {
it('reference-counts one physical lease across backend instances in the process', async () => {
const dir = await freshRoot()
const contexts = [new Context(), new Context()]
for (const ctx of contexts) {
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
}
try {
const first = await contexts[0]!.sessionPersistence.claimLive(SessionId('shared-live'))
const second = await contexts[1]!.sessionPersistence.claimLive(SessionId('shared-live'))
await first.release()
await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(true)
await second.release()
await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(false)
} finally {
await Promise.all(contexts.map(ctx => ctx.fiber.dispose()))
}
})
it('disables another live owner and reclaims its lease after the process exits', async () => {
const dir = await freshRoot()
const marker = join(dir, 'lease-held')
const child = spawn(process.execPath, ['--import', tsxLoader, leaseChild, dir, marker], {
cwd: repoRoot,
env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
stdio: ['ignore', 'ignore', 'pipe'],
})
let stderr = ''
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { stderr += chunk })
try {
await vi.waitFor(() => access(marker), { timeout: 30_000 })
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
try {
await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(true)
await expect(ctx.sessionPersistence.claimLive(SessionId('leased-session')))
.rejects.toThrow('occupied by another live process')
const closed = new Promise<void>(resolve => child.once('close', () => { resolve() }))
child.kill()
await closed
await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(false)
const leasePath = join(dir, '.live', `${encodeSegment('leased-session')}.lock`)
await writeFile(leasePath, `${JSON.stringify({ pid: child.pid, nonce: 'dead-owner' })}\n`)
const claim = await ctx.sessionPersistence.claimLive(SessionId('leased-session'))
await claim.release()
} finally {
await ctx.fiber.dispose()
}
} catch (error) {
throw new Error(`live-lease child failed: ${stderr}`, { cause: error })
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill()
}
}, 40_000)
it('fails closed on malformed lease records and surfaces lease read errors', async () => {
const dir = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
const liveDir = join(dir, '.live')
await mkdir(liveDir, { recursive: true })
try {
const malformed = [
'not json',
JSON.stringify(null),
JSON.stringify({ pid: 1.5, nonce: 'x' }),
JSON.stringify({ pid: 0, nonce: 'x' }),
JSON.stringify({ pid: process.pid, nonce: 1 }),
JSON.stringify({ pid: process.pid, nonce: '' }),
]
for (const [index, content] of malformed.entries()) {
const id = SessionId(`malformed-${index}`)
const path = join(liveDir, `${encodeSegment(id)}.lock`)
await writeFile(path, content)
await expect(ctx.sessionPersistence.isLive(id)).resolves.toBe(true)
await expect(ctx.sessionPersistence.claimLive(id)).rejects.toThrow('occupied by another live process')
}
const unreadable = SessionId('unreadable-lease')
await mkdir(join(liveDir, `${encodeSegment(unreadable)}.lock`))
await expect(ctx.sessionPersistence.isLive(unreadable)).rejects.toThrow()
const replaced = SessionId('replaced-release')
const claim = await ctx.sessionPersistence.claimLive(replaced)
const replacedPath = join(liveDir, `${encodeSegment(replaced)}.lock`)
await writeFile(replacedPath, JSON.stringify({ pid: process.pid, nonce: 'replacement' }))
await claim.release()
expect(await readFile(replacedPath, 'utf8')).toContain('replacement')
const inherited = SessionId('inherited-owner')
const inheritedPath = join(liveDir, `${encodeSegment(inherited)}.lock`)
await writeFile(inheritedPath, JSON.stringify(sessionLiveOwner()))
await expect(ctx.sessionPersistence.isLive(inherited)).resolves.toBe(true)
const inheritedClaim = await ctx.sessionPersistence.claimLive(inherited)
await inheritedClaim.release()
await expect(ctx.sessionPersistence.claimLive(SessionId('x'.repeat(300))))
.rejects.toThrow()
const guarded = SessionId('guarded-reclaim')
const guardedPath = join(liveDir, `${encodeSegment(guarded)}.lock`)
await writeFile(guardedPath, JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' }))
await writeFile(`${guardedPath}.reclaim`, 'busy')
await expect(ctx.sessionPersistence.claimLive(guarded))
.rejects.toThrow('reclamation is already in progress')
} finally {
await ctx.fiber.dispose()
}
})
it('allows exactly one process to reclaim a stale lease', async () => {
const dir = await freshRoot()
const liveDir = join(dir, '.live')
await mkdir(liveDir, { recursive: true })
const sessionId = SessionId('reclaim-race')
await writeFile(
join(liveDir, `${encodeSegment(sessionId)}.lock`),
JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' }),
)
const gate = join(dir, 'race-start')
const markers = [join(dir, 'race-a'), join(dir, 'race-b')]
const children = markers.map(marker => spawn(
process.execPath,
['--import', tsxLoader, leaseRaceChild, dir, gate, marker, sessionId],
{
cwd: repoRoot,
env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
stdio: ['ignore', 'ignore', 'pipe'],
},
))
const errors = ['', '']
children.forEach((child, index) => {
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => { errors[index] = (errors[index] ?? '') + chunk })
})
try {
await writeFile(gate, 'go')
await vi.waitFor(() => Promise.all(markers.map(marker => access(marker))), { timeout: 30_000 })
const outcomes = await Promise.all(markers.map(marker => readFile(marker, 'utf8')))
expect(outcomes.filter(outcome => outcome === 'claimed')).toHaveLength(1)
expect(outcomes.filter(outcome => outcome.startsWith('rejected:'))).toHaveLength(1)
const winner = children[outcomes.findIndex(outcome => outcome === 'claimed')]!
const loser = children[outcomes.findIndex(outcome => outcome.startsWith('rejected:'))]!
if (loser.exitCode === null && loser.signalCode === null) {
await new Promise<void>(resolve => loser.once('close', () => { resolve() }))
}
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
try {
await expect(ctx.sessionPersistence.claimLive(sessionId))
.rejects.toThrow('occupied by another live process')
} finally {
await ctx.fiber.dispose()
}
const closed = new Promise<void>(resolve => winner.once('close', () => { resolve() }))
winner.kill()
await closed
} catch (error) {
throw new Error(`live-lease race children failed: ${errors.join('\n')}`, { cause: error })
} finally {
for (const child of children) {
if (child.exitCode === null && child.signalCode === null) child.kill()
}
}
}, 40_000)
})
describe('SessionPersistenceJsonl: durability and crash semantics', () => {
let ctx: Context
beforeEach(async () => {

View File

@@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
## Storage model
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)``data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)``data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id, and `live_session_leases` stores one PID and exec-stable nonce per live session. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations.
@@ -33,7 +33,7 @@ interface Config {
## Write path
Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database.
Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. A live lease is acquired in a `BEGIN IMMEDIATE` transaction before flush or resume and released after the exact lifecycle retires. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database.
## Model Experience

View File

@@ -15,8 +15,9 @@ import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import {
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
type StoredPrefix,
sessionLeaseProcessIsLive, shareSessionLiveLease,
type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner,
type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import {
@@ -161,6 +162,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
return this.coordinator.inspect(id)
}
override claimLive(id: SessionId): Promise<SessionLiveLease> {
return this.coordinator.claimLive(id)
}
override isLive(id: SessionId): Promise<boolean> {
return this.coordinator.isLive(id)
}
// One method serves both public `list` and the backend hook; delegating it to
// the coordinator would call this hook recursively.
@@ -271,6 +280,55 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
}))
}
/** Atomically acquire one SQLite-backed process lease. */
async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> {
await this.ready
return shareSessionLiveLease(
`sqlite:${this.storeIdentity}:${id}`,
() => Promise.resolve().then(() => this.acquireLiveRow(id, owner)),
)
}
private acquireLiveRow(id: SessionId, owner: SessionLiveOwner): () => Promise<void> {
this.db.exec('BEGIN IMMEDIATE')
try {
const current = this.liveLeaseFor(id)
if (current !== undefined
&& (current.pid !== owner.pid || current.nonce !== owner.nonce)) {
if (sessionLeaseProcessIsLive(current.pid)) {
throw new Error(`session "${id}" is occupied by another live process`)
}
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id)
}
this.db.prepare(`
INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET pid = excluded.pid, nonce = excluded.nonce
`).run(id, owner.pid, owner.nonce)
this.db.exec('COMMIT')
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
return async () => {
await this.ready
this.db.prepare(
'DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?',
).run(id, owner.pid, owner.nonce)
}
}
/** Report a non-stale SQLite lease and remove a crashed owner's row. */
async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> {
await this.ready
const current = this.liveLeaseFor(id)
if (current === undefined) return false
if ((current.pid === owner.pid && current.nonce === owner.nonce)
|| sessionLeaseProcessIsLive(current.pid)) 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
}
/** Close the database handle (awaited by the coordinator's dispose, post-drain). */
async close(): Promise<void> {
await this.ready
@@ -284,6 +342,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined
}
private liveLeaseFor(id: SessionId): { pid: number; nonce: string } | undefined {
return this.db.prepare('SELECT pid, nonce FROM live_session_leases WHERE session_id = ?')
.get(id) as { pid: number; nonce: string } | undefined
}
/**
* Insert-or-replace a session's metadata row. The only caller is the first
* materializing `appendBatch`, so writing the row IS the materialization (its

View File

@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
* layout; orthogonal to a session's own `version` (which versions the EVENT
* vocabulary, stored per session in the `sessions` row).
*/
export const SCHEMA_VERSION = 8
export const SCHEMA_VERSION = 9
/**
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
@@ -68,7 +68,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
* rather than being migrated in place.
* @param path - the SQLite database file to open (created when absent).
* @param journalMode - validated journal pragma.
* @returns the open handle with pragmas applied and all three tables ensured.
* @returns the open handle with pragmas applied and all tables ensured.
*/
export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
const db = new DatabaseSync(path)
@@ -128,6 +128,13 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
PRIMARY KEY (session_id, seq)
) STRICT
`)
db.exec(`
CREATE TABLE IF NOT EXISTS live_session_leases (
session_id TEXT PRIMARY KEY,
pid INTEGER NOT NULL,
nonce TEXT NOT NULL
) STRICT
`)
}
/**

View File

@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence'
import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts'
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
@@ -442,7 +443,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(8)
expect(SCHEMA_VERSION).toBe(9)
})
it('keeps the revision stable for an empty repair hook', async () => {
@@ -458,6 +459,38 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
describe('SessionPersistenceSqlite: edge cases', () => {
it('claims, rejects, reclaims, inspects, and releases SQLite live leases', async () => {
const path = await freshDbPath()
const b = await backend(path)
await b.ctx.sessionPersistence.list()
const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite
const owner = sessionLiveOwner()
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('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)
db.close()
await expect(concrete.acquireLive(SessionId('occupied-lease'), owner))
.rejects.toThrow('occupied by another live process')
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 b.dispose()
const memory = new Context()
await memory.plugin(SessionStore)
await memory.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const memoryClaim = await memory.sessionPersistence.claimLive(SessionId('memory-live'))
expect(await memory.sessionPersistence.isLive(SessionId('memory-live'))).toBe(true)
await memoryClaim.release()
await memory.fiber.dispose()
})
it('rejects and closes a current-schema database with an invalid store identity', async () => {
const path = await freshDbPath()
const db = openDatabase(path, 'wal')

View File

@@ -15,6 +15,10 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
| `inspect(id): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; intended for read models and other observers that must never recover a log. |
| `list(): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. |
| `listSnapshots(): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. |
| `claimLive(id): Promise<SessionLiveLease>` | Atomically claim live ownership. First-party backends reject another live process and reclaim a dead owner; release follows quiescence. |
| `isLive(id): Promise<boolean>` | Report a current non-stale live lease, including one owned by this process. |
The abstract base supplies a process-local fallback for lightweight third-party implementations. A backend that needs multi-process safety overrides both live-lease methods.
## Invariants every backend must honor
@@ -31,7 +35,7 @@ Each `session/event` copies its event into the session controller and starts an
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn.
When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle.
When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state and the backend-owned live lease for that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, releases their leases, awaits per-id operations, and only then closes the storage handle.
The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration.
@@ -44,6 +48,8 @@ The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinato
| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). |
| `list()` | List all stored metadata. |
| `acquireLive?(id, owner)` | Atomically acquire a backend-owned cross-process lease and return its physical release. |
| `inspectLive?(id, owner)` | Report or reclaim a backend-owned lease without acquiring it. |
| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).

View File

@@ -8,6 +8,8 @@
import { Context } from 'cordis'
import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import { sessionLiveOwner } from './lease.ts'
import type { SessionLiveLease, SessionLiveOwner } from './lease.ts'
/**
* A stored session's header, valid contiguous event prefix, and optional opaque
@@ -63,6 +65,12 @@ export interface PersistenceBackend<TornMarker = unknown> {
/** List all stored (materialized) sessions' metadata. */
list(): Promise<SessionHeader[]>
/** Optionally acquire a backend-owned cross-process live-session lease. */
acquireLive?(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>>
/** Optionally inspect and reclaim a backend-owned live-session lease. */
inspectLive?(id: SessionId, owner: SessionLiveOwner): Promise<boolean>
/**
* Optional lifecycle teardown (e.g. close a database handle). Awaited by the
* coordinator's dispose effect AFTER the quiescence drain. A stateless file
@@ -96,6 +104,7 @@ interface LiveSessionState {
pending: SessionEvent[]
init: Promise<void>
flush: Promise<void> | undefined
lease?: SessionLiveLease
}
/** Collect the rejection reasons from a set of promises (none-throwing). */
@@ -161,6 +170,12 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* same id, so writes for one session never interleave. Keyed by session id.
*/
private chains = new Map<SessionId, Promise<unknown>>()
/** One backend lease with process-local reference counting per session id. */
private liveClaims = new Map<SessionId, {
refs: number
releaseBackend: () => Promise<void>
}>()
private readonly liveOwner = sessionLiveOwner()
constructor(private ctx: Context, private backend: PersistenceBackend<TornMarker>) {
this.installWritePath()
@@ -273,6 +288,61 @@ export class PersistenceCoordinator<TornMarker = unknown> {
return this.serialize(id, () => this.inspectCore(id))
}
/**
* Acquire one process-local reference to the backend's cross-process lease.
* @param id - session identity about to become live.
* @returns one idempotent release capability.
*/
async claimLive(id: SessionId): Promise<SessionLiveLease> {
const acquireLive = this.backend.acquireLive?.bind(this.backend)
if (acquireLive === undefined) return { release: () => Promise.resolve() }
await this.serialize(id, async () => {
const existing = this.liveClaims.get(id)
if (existing !== undefined) {
existing.refs += 1
return
}
const releaseBackend = await acquireLive(id, this.liveOwner)
this.liveClaims.set(id, { refs: 1, releaseBackend })
})
let releaseTask: Promise<void> | undefined
return {
release: () => {
if (releaseTask !== undefined) return releaseTask
const task = this.serialize(id, async () => {
const claim = this.liveClaims.get(id)
/* v8 ignore next -- this capability is returned only after its claim enters the serialized map */
if (claim === undefined) return
claim.refs -= 1
if (claim.refs > 0) return
try {
await claim.releaseBackend()
} catch (error) {
claim.refs += 1
throw error
}
this.liveClaims.delete(id)
})
const wrapped = task.catch((error: unknown) => {
releaseTask = undefined
throw error
})
releaseTask = wrapped
return wrapped
},
}
}
/**
* Check the backend's current cross-process lease state.
* @param id - session identity to inspect.
* @returns whether this or another live process owns the session.
*/
isLive(id: SessionId): Promise<boolean> {
if (this.liveClaims.has(id)) return Promise.resolve(true)
return this.backend.inspectLive?.(id, this.liveOwner) ?? Promise.resolve(false)
}
private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const stored = await this.backend.loadStored(id)
if (stored === undefined) throw new Error(`session "${id}" not found`)
@@ -382,6 +452,9 @@ export class PersistenceCoordinator<TornMarker = unknown> {
let disposeError: unknown
try {
const errors = await settledErrors([...this.live.keys()].map(session => this.flush(session)))
errors.push(...await settledErrors(
[...this.live.values()].flatMap(live => live.lease === undefined ? [] : [live.lease.release()]),
))
while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()])
if (errors.length > 0) {
throw new AggregateError(errors, `${this.backend.name} dispose failed`)
@@ -441,6 +514,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private async retireCore(session: Session): Promise<void> {
await this.flush(session)
const id = session.header.id
const live = this.live.get(session)
await live?.lease?.release()
await this.serialize(id, () => {
this.live.delete(session)
if (this.states.get(id)?.owner === session) this.states.delete(id)
@@ -454,7 +529,16 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const seed = session.events.map(e => structuredClone(e))
const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined }
this.live.set(session, live)
live.init = this.serialize(session.header.id, () => this.onCreated(session, seed))
live.init = this.claimLive(session.id).then(async (lease) => {
live.lease = lease
try {
await this.serialize(session.header.id, () => this.onCreated(session, seed))
} catch (error) {
delete live.lease
await lease.release()
throw error
}
})
live.init.catch(() => { /* observed by flush/dispose through the controller */ })
return live
}

View File

@@ -8,10 +8,13 @@
import { Context, Service } from 'cordis'
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionPersistenceRevision } from './revision.ts'
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 type { SessionLiveLease, SessionLiveOwner } from './lease.ts'
/** Lightweight immutable source identity returned without loading a full log. */
export interface SessionPersistenceSnapshot {
@@ -50,6 +53,8 @@ export interface SessionLocation {
* rewriting committed events.
*/
export abstract class SessionPersistence extends Service {
private readonly localLiveClaims = new Map<SessionId, number>()
constructor(ctx: Context) {
super(ctx, 'sessionPersistence')
}
@@ -123,6 +128,39 @@ export abstract class SessionPersistence extends Service {
* @returns one header and opaque revision per materialized session without loading full logs.
*/
abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>
/**
* Atomically acquire this process's live ownership of a session id.
* Reentrant claims share one backend lease. First-party backends override
* this process-local fallback to reject another live process and reclaim a
* dead owner.
* @param id - session identity that is about to become live.
* @returns a single-release reference owned by the caller.
*/
claimLive(id: SessionId): Promise<SessionLiveLease> {
this.localLiveClaims.set(id, (this.localLiveClaims.get(id) ?? 0) + 1)
let released = false
return Promise.resolve({
release: () => {
if (released) return Promise.resolve()
released = true
const refs = this.localLiveClaims.get(id) as number
if (refs <= 1) this.localLiveClaims.delete(id)
else this.localLiveClaims.set(id, refs - 1)
return Promise.resolve()
},
})
}
/**
* Check whether any process currently owns a live lease for this session.
* The base implementation reports only claims on this service instance.
* @param id - persisted or prospective session identity.
* @returns true while a non-stale lease exists, including this process's lease.
*/
isLive(id: SessionId): Promise<boolean> {
return Promise.resolve(this.localLiveClaims.has(id))
}
}
export default SessionPersistence

View File

@@ -0,0 +1,98 @@
/** Process-backed identity helpers for cross-process live-session leases. */
import { randomUUID } from 'node:crypto'
const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER'
/** Process identity stored in backend-owned cross-process live-session leases. */
export interface SessionLiveOwner {
/** Operating-system process id; retained across an `execve` handoff. */
readonly pid: number
/** Per-process-start nonce that distinguishes PID reuse. */
readonly nonce: string
}
/** Idempotent capability releasing one acquired live-session lease reference. */
export interface SessionLiveLease {
/** Release this caller's lease reference after its live session reaches quiescence. */
release(): Promise<void>
}
/**
* Stable owner inherited only by an exec-replaced process, not inferred from a session id.
* @returns this process's PID and exec-stable nonce.
*/
export function sessionLiveOwner(): SessionLiveOwner {
const nonce = process.env[LIVE_OWNER_ENV] ?? randomUUID()
process.env[LIVE_OWNER_ENV] = nonce
return { pid: process.pid, nonce }
}
/**
* Whether a lease pid still names a process; permission denial counts as live.
* @param pid - positive operating-system process id from a lease record.
* @returns true unless the operating system reports that the process is absent.
*/
export function sessionLeaseProcessIsLive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH'
}
}
interface SharedLeaseEntry {
refs: number
readonly acquired: Promise<() => Promise<void>>
}
const sharedLeases = new Map<string, SharedLeaseEntry>()
/**
* Reference-count one physical lease across backend instances in this process.
* @param key - backend-kind plus canonical storage location and session id.
* @param acquire - single physical acquisition performed for the first reference.
* @returns an idempotent release for this caller's reference.
*/
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 () => {
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
}
}

View File

@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
import {
sessionLeaseProcessIsLive,
sessionLiveOwner,
shareSessionLiveLease,
} from '../src/lease.ts'
const originalOwner = process.env.DSH_SESSION_LIVE_OWNER
afterEach(() => {
vi.restoreAllMocks()
if (originalOwner === undefined) delete process.env.DSH_SESSION_LIVE_OWNER
else process.env.DSH_SESSION_LIVE_OWNER = originalOwner
})
describe('process live-session lease helpers', () => {
it('creates one exec-stable owner identity and classifies process liveness', () => {
delete process.env.DSH_SESSION_LIVE_OWNER
const first = sessionLiveOwner()
expect(first.pid).toBe(process.pid)
expect(typeof first.nonce).toBe('string')
expect(sessionLiveOwner()).toEqual(first)
expect(sessionLeaseProcessIsLive(process.pid)).toBe(true)
const missing = Object.assign(new Error('missing'), { code: 'ESRCH' })
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 })
expect(sessionLeaseProcessIsLive(999_998)).toBe(true)
})
it('shares one physical lease until every process-local reference releases', async () => {
const releasePhysical = vi.fn<() => Promise<void>>(() => Promise.resolve())
const acquire = vi.fn<() => Promise<() => Promise<void>>>(() => Promise.resolve(releasePhysical))
const key = `shared-${randomUUID()}`
const first = await shareSessionLiveLease(key, acquire)
const second = await shareSessionLiveLease(key, acquire)
expect(acquire).toHaveBeenCalledTimes(1)
await first()
expect(releasePhysical).not.toHaveBeenCalled()
await second()
await second()
expect(releasePhysical).toHaveBeenCalledTimes(1)
})
it('removes failed acquisitions and retries a failed physical release', async () => {
const key = `retry-${randomUUID()}`
await expect(shareSessionLiveLease(key, () => Promise.reject(new Error('claim failed'))))
.rejects.toThrow('claim failed')
let releases = 0
const release = await shareSessionLiveLease(key, () => Promise.resolve(async () => {
releases += 1
if (releases === 1) throw new Error('release failed')
}))
await expect(release()).rejects.toThrow('release failed')
await expect(release()).resolves.toBeUndefined()
expect(releases).toBe(2)
})
})

View File

@@ -4,7 +4,7 @@ import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import {
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix,
type PersistenceBackend, type SessionLiveOwner, type SessionPersistenceSnapshot, type StoredPrefix,
} from '../src/index.ts'
import { runPersistenceContract, meta, oneTurnLog } from './contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts'
@@ -348,6 +348,46 @@ describe('PersistenceCoordinator stored identity', () => {
})
})
describe('PersistenceCoordinator live leases', () => {
it('degrades without backend hooks and retries a failed final release', async () => {
const fallbackCtx = new Context()
await fallbackCtx.plugin(SessionStore)
const fallback = new PersistenceCoordinator(fallbackCtx, new ControlledBackend())
const fallbackClaim = await fallback.claimLive(SessionId('fallback-live'))
expect(await fallback.isLive(SessionId('fallback-live'))).toBe(false)
await fallbackClaim.release()
await fallbackCtx.fiber.dispose()
class LeaseBackend extends ControlledBackend {
releaseAttempts = 0
async acquireLive(_id: SessionId, _owner: SessionLiveOwner): Promise<() => Promise<void>> {
return async () => {
this.releaseAttempts += 1
if (this.releaseAttempts === 1) throw new Error('lease release failed')
}
}
inspectLive(): Promise<boolean> {
return Promise.resolve(true)
}
}
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new LeaseBackend()
const coordinator = new PersistenceCoordinator(ctx, backend)
const first = await coordinator.claimLive(SessionId('leased'))
const second = await coordinator.claimLive(SessionId('leased'))
expect(await coordinator.isLive(SessionId('leased'))).toBe(true)
await first.release()
await expect(second.release()).rejects.toThrow('lease release failed')
await expect(second.release()).resolves.toBeUndefined()
await expect(second.release()).resolves.toBeUndefined()
expect(backend.releaseAttempts).toBe(2)
expect(await coordinator.isLive(SessionId('leased'))).toBe(true)
await ctx.fiber.dispose()
})
})
describe('PersistenceCoordinator retirement', () => {
it('a retiring unmaterialized owner without buffered events releases its id', async () => {
const ctx = new Context()
@@ -795,4 +835,20 @@ describe('SessionPersistence service registration', () => {
await fiber.dispose()
}
})
it('provides a reference-counted process-local lease fallback', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(MemoryPersistence)
const id = SessionId('local-live')
const first = await ctx.sessionPersistence.claimLive(id)
const second = await ctx.sessionPersistence.claimLive(id)
expect(await ctx.sessionPersistence.isLive(id)).toBe(true)
await first.release()
await first.release()
expect(await ctx.sessionPersistence.isLive(id)).toBe(true)
await second.release()
expect(await ctx.sessionPersistence.isLive(id)).toBe(false)
await ctx.fiber.dispose()
})
})

View File

@@ -5,6 +5,7 @@
## Reads
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
- `readSession(sessionId)` returns one complete detached raw log after the same core replay validation used by resume; it never enters the session into the live store.
- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
- `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title.

View File

@@ -5,7 +5,7 @@
*/
import { Context, Service } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { Session, type SessionId } from '@deepseek-ai/dsh-session'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
import type {
@@ -19,6 +19,7 @@ import type {
SessionEventTraceRequest,
SessionEventWindow,
SessionLineageTrace,
SessionLogSnapshot,
SessionRecord,
SessionResultFilter,
SessionSearchExecContext,
@@ -118,6 +119,21 @@ export abstract class SessionQueryService extends Service {
return this._corpus.listSessions()
}
/**
* Read and replay-validate one complete logical session log without making it live.
* @param sessionId - live or persisted session id to read.
* @returns cloned header and complete raw event log from one observation.
* @throws when persistence, header compatibility, or replay validation fails.
*/
async readSession(sessionId: SessionId): Promise<SessionLogSnapshot> {
const loaded = await this._corpus.load(sessionId)
new Session(sessionId, loaded.events, loaded.header)
return {
session: structuredClone(loaded.header),
events: loaded.events.map(event => structuredClone(event)),
}
}
/**
* Filter the complete logical corpus with provider-independent predicates.
* @param filters - ANDed session metadata and availability clauses.

View File

@@ -39,6 +39,14 @@ export interface SessionSurfaceSnapshot {
events: SurfaceEvent[]
}
/** One validated detached observation of a logical session's complete raw log. */
export interface SessionLogSnapshot {
/** Cloned session header selected from the same observation as `events`. */
session: SessionHeader
/** Cloned contiguous raw events after persistence repair and replay validation. */
events: SessionEvent[]
}
/** Lightweight metadata for one event within a logical session. */
export interface SessionEventRecord {
/** Session that owns the event. */

View File

@@ -105,6 +105,25 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
}
describe('session-query exact reads', () => {
it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => {
const valid = header('valid-log', 2)
const corrupt = header('corrupt-log', 1)
const validEvents = eventLog('valid')
const corruptEvents = [{ ...eventLog('bad')[0]!, seq: 1 }]
TestPersistence.reset([
{ meta: valid, events: validEvents },
{ meta: corrupt, events: corruptEvents },
])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const snapshot = await ctx.sessionQuery.readSession(valid.id)
expect(snapshot).toEqual({ session: valid, events: validEvents })
Object.assign(snapshot.events[0]!, { time: 999 })
expect(TestPersistence.entries.get(valid.id)?.events[0]?.time).toBe(10)
await expect(ctx.sessionQuery.readSession(corrupt.id)).rejects.toThrow('seed event at index 0 has seq 1')
})
it('prefers a live owner that attaches while its persisted prefix is inspected', async () => {
const shared = header('attach-during-inspect', 2)
TestPersistence.reset([{ meta: shared, events: eventLog('persisted') }])

View File

@@ -6,11 +6,12 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ
|---|---|
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
| `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh |
| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume <sessionId>` pair while preserving positional arguments |
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `boot(binName, absoluteConfigPath, patches?)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL with the optional overlay patches, await the whole tree, assert entries loaded, return the root context |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |

View File

@@ -80,6 +80,18 @@ export function parseResumeArg(
return { resumeSessionId, rest }
}
/**
* Replace any existing resume flag with one canonical trailing `--resume <id>` pair.
* @param argv - current arguments after command dispatch.
* @param sessionId - selected session id.
* @returns flag-normalized arguments for a process replacement.
*/
export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] {
if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`)
const { rest } = parseResumeArg(argv)
return [...rest, RESUME_FLAG, sessionId]
}
/**
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
* ambient environment; other read failures are reported through `warn`.
@@ -216,12 +228,17 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
* (see {@link resolveConfigPath}).
* @param patches - optional overlay patches applied over the included tree
* (see {@link loadPersonalPatches}); an empty list mounts none.
* @param prepare - optional host setup run against the root context before any Loader entry mounts.
* @returns the root context once every entry has started.
*/
export async function boot(
binName: string, absoluteConfigPath: string, patches?: PatchOptions[],
binName: string,
absoluteConfigPath: string,
patches?: PatchOptions[],
prepare?: (ctx: Context) => Promise<void> | void,
): Promise<Context> {
const ctx = new Context()
await prepare?.(ctx)
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include

View File

@@ -6,7 +6,7 @@ import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess,
installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -55,6 +55,15 @@ describe('parseResumeArg', () => {
})
})
describe('replaceResumeArg', () => {
it('keeps positional arguments and replaces either existing flag form', () => {
expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(() => replaceResumeArg([], '')).toThrow('non-empty session id')
})
})
describe('loadEnv', () => {
it('loads variables from .env in the given dir', () => {
const dir = tmp()
@@ -196,6 +205,19 @@ describe('boot', () => {
}
})
it('runs host preparation before the Loader tree mounts', async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const prepared: Context[] = []
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
try {
expect(prepared).toEqual([ctx])
} finally {
await ctx.fiber.dispose()
}
})
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')

View File

@@ -30,7 +30,9 @@ 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.
When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiting prints the resume command for the current session (once it has been persisted, so an abandoned session yields no hint), and `/resume` lists this workspace's persisted sessions newest-first, each with its resume command and a marker on the current one. `{session}` in the template expands to the session id; the TUI only prints commands to copy and never resumes in place.
`/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`.
`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.
## Config
@@ -42,17 +44,20 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
| `maxQuestionOptions` | `8` | Visible options in a question panel |
| `maxModelOptions` | `8` | Visible models in the model selector |
| `maxResumeOptions` | `8` | Visible sessions in the resume selector |
| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal |
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
| `modelDialogWidth` | `72` | Model-selector width in columns |
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
| `resumeDialogWidth` | `88` | Resume-selector width in columns |
| `resumeDialogMaxHeight` | `24` | Resume-selector maximum rows |
| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query |
| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion |
| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
| `title` | `DeepSeek Harness` | Product suffix for the terminal window title. |
| `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend |
| `resumeCommand` | — | Shell command template for the exit hint and hosts without in-place handoff, with `{session}` expanded to the session id |
```yaml
- id: terminal

View File

@@ -33,9 +33,11 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -48,6 +50,12 @@
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
"@deepseek-ai/dsh-session-query": {
"optional": true
},
"@deepseek-ai/dsh-goal": {
"optional": true
},
"@deepseek-ai/dsh-skill": {
"optional": true
}
@@ -60,6 +68,7 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -66,12 +66,17 @@ import {
type SessionHeader,
type TodoItem,
} from '@deepseek-ai/dsh-session'
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
import {
formatSessionReferenceMention,
parseSessionReferenceText,
type SessionReferenceService,
} from '@deepseek-ai/dsh-session-reference'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
SessionLogSnapshot,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
// Side-effect type import: declaration-merges the optional `sessionPersistence`
// service onto `Context` so `ctx.get('sessionPersistence')` is typed.
import type {} from '@deepseek-ai/dsh-session-persistence'
@@ -120,9 +125,22 @@ declare module 'cordis' {
interface Context {
/** Terminal-only interaction service, available only while a TUI is mounted. */
tui: TuiExtensionService
/** Optional process host that can replace this TUI with a resumed session. */
tuiResumeHost: TuiResumeHost
}
}
/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */
export interface TuiResumeHost {
/**
* Dispose the current app and replace it with a runtime for `sessionId`.
* Success does not return. A host may reject before it commits teardown;
* after commit it owns fatal reporting and process exit.
* @param sessionId - validated persisted session selected by the user.
*/
handoff(sessionId: SessionId): Promise<never>
}
/**
* Optional terminal-local interaction service provided by one mounted TUI.
*
@@ -162,7 +180,7 @@ export {
} from './file-autocomplete.ts'
export const name = 'ui-tui'
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
/** Model guidance for path-only file references selected through the TUI. */
export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.'
@@ -177,6 +195,8 @@ export interface TuiConfig {
maxQuestionOptions?: number
/** Maximum models visible at once in the model selector. */
maxModelOptions?: number
/** Maximum sessions visible at once in the resume selector. */
maxResumeOptions?: number
/** User-question panel width in terminal columns, clamped to the terminal. */
questionDialogWidth?: number
/** User-question panel maximum height in terminal rows. */
@@ -185,6 +205,10 @@ export interface TuiConfig {
modelDialogWidth?: number
/** Model-selector maximum height in terminal rows. */
modelDialogMaxHeight?: number
/** Resume-selector width in terminal columns. */
resumeDialogWidth?: number
/** Resume-selector maximum height in terminal rows. */
resumeDialogMaxHeight?: number
/** Maximum fuzzy file candidates displayed for one `@` query. */
fileSearchMaxResults?: number
/** Maximum paths retained in one `@` workspace index. */
@@ -210,10 +234,13 @@ const showReasoningSchema = z.boolean().default(true)
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const resumeDialogWidthSchema = z.number().step(1).min(36).default(88)
const resumeDialogMaxHeightSchema = z.number().step(1).min(8).default(24)
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
@@ -228,10 +255,13 @@ const tuiConfigSchemaFields = {
maxToolOutputLines: maxToolOutputLinesSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
maxResumeOptions: maxResumeOptionsSchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
resumeDialogWidth: resumeDialogWidthSchema,
resumeDialogMaxHeight: resumeDialogMaxHeightSchema,
fileSearchMaxResults: fileSearchMaxResultsSchema,
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
@@ -251,11 +281,10 @@ export interface Config extends TuiConfig {
/** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
sessionId?: string
/**
* Shell command template shown for resuming this session: printed on exit and
* listed by `/resume`, with every `{session}` occurrence replaced by the live
* session id. Absent disables both surfaces. Deployments set it only when a
* persistence backend makes the session resumable (e.g.
* `RESUME_SESSION_ID={session} dsh`).
* Shell command fallback printed on exit or after selecting a session when
* the host cannot hand off in place. Every `{session}` becomes the selected
* id; the TUI never executes this text. Absent disables only the fallback,
* not the interactive selector.
*/
resumeCommand?: string
}
@@ -268,10 +297,13 @@ export const Config: z<Config> = z.object({
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth,
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
resumeDialogWidth: tuiConfigSchemaFields.resumeDialogWidth,
resumeDialogMaxHeight: tuiConfigSchemaFields.resumeDialogMaxHeight,
fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
@@ -287,10 +319,13 @@ export interface ResolvedTuiConfig {
maxToolOutputLines: number
maxQuestionOptions: number
maxModelOptions: number
maxResumeOptions: number
questionDialogWidth: number
questionDialogMaxHeight: number
modelDialogWidth: number
modelDialogMaxHeight: number
resumeDialogWidth: number
resumeDialogMaxHeight: number
fileSearchMaxResults: number
fileSearchMaxEntries: number
fileSearchExcludedDirectories: string[]
@@ -314,6 +349,8 @@ 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. */
handoffResume?: TuiResumeHost['handoff']
}
/**
@@ -328,10 +365,13 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
maxToolOutputLines: config?.maxToolOutputLines ?? 6,
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
maxModelOptions: config?.maxModelOptions ?? 8,
maxResumeOptions: config?.maxResumeOptions ?? 8,
questionDialogWidth: config?.questionDialogWidth ?? 200,
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 72,
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
resumeDialogWidth: config?.resumeDialogWidth ?? 88,
resumeDialogMaxHeight: config?.resumeDialogMaxHeight ?? 24,
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
@@ -1236,6 +1276,173 @@ class ModelDialog implements Component {
}
}
interface ResumeRoute {
provider: string
model: string
}
interface ResumeCandidate {
record: SessionRecord
occupied: boolean
title: string
lastActivityAt: number
lastTurn: string
route?: ResumeRoute
goalPhase?: GoalPhase
disabledReason?: string
}
function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
const event = snapshot.events.findLast(item => item.type === 'turn/end')
if (event === undefined) return 'no completed turn'
const reason = event.data.reason
switch (reason.kind) {
case 'completed': return `turn ${event.data.turn}: completed`
case 'aborted': return `turn ${event.data.turn}: cancelled`
case 'error': return `turn ${event.data.turn}: error`
case 'disposed': return `turn ${event.data.turn}: disposed`
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
case 'rejected': return `turn ${event.data.turn}: rejected`
case 'interrupted': return `turn ${event.data.turn}: interrupted`
default: return `turn ${event.data.turn}: unknown result`
}
}
function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
const header = snapshot.events.findLast(item => item.type === 'request/header')
if (header?.type === 'request/header') {
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
}
const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
return assistant?.type === 'assistant/message'
? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model }
: undefined
}
function summarizeResumeCandidate(
record: SessionRecord,
snapshot: SessionLogSnapshot,
currentId: SessionId,
cwd: string | undefined,
occupied: boolean,
availableProviders: ReadonlySet<string>,
): ResumeCandidate {
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
const route = resumeRoute(snapshot)
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.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),
...route === undefined ? {} : { route },
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
...disabledReason === undefined ? {} : { disabledReason },
}
}
/** Searchable keyboard selector over detached, preflighted resume summaries. */
class ResumeDialog implements Component, Focusable {
private query = ''
private selectedIndex = 0
private error = ''
focused = false
constructor(
private readonly candidates: readonly ResumeCandidate[],
private readonly maxVisible: number,
private readonly palette: Palette,
private readonly done: (candidate: ResumeCandidate) => void,
private readonly cancel: () => void,
) {}
invalidate(): void {}
private filtered(): ResumeCandidate[] {
const query = this.query.trim().toLocaleLowerCase()
if (query === '') return [...this.candidates]
return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
|| candidate.record.header.id.toLocaleLowerCase().includes(query))
}
handleInput(data: string): void {
this.invalidate()
const filtered = this.filtered()
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
this.cancel()
return
}
if (matchesKey(data, Key.up)) {
this.selectedIndex = filtered.length === 0
? 0
: (this.selectedIndex + filtered.length - 1) % filtered.length
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length
} else if (matchesKey(data, Key.enter)) {
const selected = filtered[this.selectedIndex]
if (selected === undefined) this.error = 'No session matches this search.'
else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
else this.done(selected)
} else if (data === '\x7f' || data === '\b') {
this.query = Array.from(this.query).slice(0, -1).join('')
this.selectedIndex = 0
this.error = ''
} else if (!Array.from(data).some(character => character < ' ' || character === '\x7f')) {
this.query += data
this.selectedIndex = 0
this.error = ''
}
}
render(width: number): string[] {
const innerWidth = Math.max(1, width - 4)
const filtered = this.filtered()
if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
const start = Math.max(0, Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2),
filtered.length - this.maxVisible,
))
const end = Math.min(filtered.length, start + this.maxVisible)
const body: string[] = [
this.query === ''
? `${this.palette.muted('Search:')} ${this.palette.dim('title or session id')}`
: this.palette.text(`Search: ${displayText(this.query)}`),
'',
]
for (let index = start; index < end; index += 1) {
const candidate = filtered[index] as ResumeCandidate
const selected = index === this.selectedIndex
const status = [
candidate.disabledReason === 'current session' ? 'current' : undefined,
candidate.record.live || candidate.occupied ? 'live' : undefined,
candidate.record.persisted ? 'persisted' : undefined,
].filter((value): value is string => value !== undefined).join(' · ')
const lead = `${selected ? '' : ' '} ${displayText(candidate.title)}`
body.push(selected ? this.palette.bold(this.palette.accent(lead)) : lead)
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
body.push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
body.push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
if (candidate.disabledReason !== undefined) {
body.push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
}
}
if (filtered.length === 0) body.push(this.palette.warning('No matching sessions.'))
if (filtered.length > this.maxVisible) body.push(this.palette.dim(`${this.selectedIndex + 1}/${filtered.length}`))
body.push('', this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc cancel'))
if (this.error !== '') body.push(this.palette.error(displayText(this.error)))
return renderDialog('Resume session', body.flatMap(line => wrapTextWithAnsi(line, innerWidth)), width, this.palette)
}
}
class QuestionDialog implements Component, Focusable {
private selectedIndex = 0
private selected = new Set<number>()
@@ -1585,6 +1792,7 @@ export function createTuiChat(
const agent = ctx.agents.get(sessionId)
if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
const persistence = ctx.get('sessionPersistence')
const sessionQuery = ctx.get('sessionQuery')
const resolved = resolveTuiConfig(config)
const palette = createPalette(resolved.color)
const mdTheme = markdownTheme(palette)
@@ -1631,6 +1839,9 @@ export function createTuiChat(
const referenceControllers = new Set<AbortController>()
let activeQuestion: PendingQuestion | undefined
let modelOverlay: TuiOverlaySession | undefined
let resumeOverlay: TuiOverlaySession | undefined
let resumeInFlight = false
let resumeScan = 0
let tuiServiceFiber: Fiber | undefined
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
let contextWindow: number | undefined
@@ -1640,6 +1851,8 @@ export function createTuiChat(
> | undefined
let modelCommands = Promise.resolve()
const now = (): number => runtime.now?.() ?? Date.now()
const agentStatus = (): AgentStatus => agent.status
const isDisposed = (): boolean => disposed
// A configured subtitle renders as a banner line; when absent, the banner has
// no subtitle. The banner itself sweeps in on start (see startBannerReveal).
@@ -2204,7 +2417,6 @@ export function createTuiChat(
}
return all
.filter(header => header.cwd === agent.session.header.cwd)
.sort((a, b) => b.createdAt - a.createdAt)
}
/**
@@ -2597,37 +2809,153 @@ export function createTuiChat(
})
}
/**
* List this workspace's resumable sessions, newest first, each with its
* resume command and a marker on the current one. Warns when resume is not
* configured or no persistence backend is mounted; notes when nothing is
* persisted yet. The listing is asynchronous (a persistence scan), so the
* transcript updates once it resolves.
*/
const showResume = (): void => {
const template = config.resumeCommand
if (template === undefined) {
appendNotice('Resume is not configured for this app.', 'warning')
return
/** Build one display candidate without letting a corrupt neighbor abort the selector. */
const readResumeCandidate = async (
record: SessionRecord,
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) {
snapshot = {
session: structuredClone(live.header),
events: live.events.map(event => structuredClone(event)),
}
} else {
/* v8 ignore next -- caller checks the optional service before mapping records */
if (sessionQuery === undefined) throw new Error('session query is unavailable')
snapshot = await sessionQuery.readSession(record.header.id)
}
return summarizeResumeCandidate(
record,
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',
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
}
}
if (persistence === undefined) {
appendNotice('Resume is not available: no persistence backend is mounted.', 'warning')
return
}
void listWorkspaceSessions().then((sessions) => {
if (sessions.length === 0) {
appendNotice('No resumable sessions found for this workspace yet.', 'info')
}
/** Re-read every mutable precondition immediately before terminal handoff. */
const preflightResume = async (sessionId: SessionId): Promise<ResumeCandidate> => {
/* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
const initialStatus = agentStatus()
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
const candidate = await readResumeCandidate(
record,
new Set(ctx.llm.listProviders().map(provider => provider.id)),
)
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
const finalStatus = agentStatus()
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
return candidate
}
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
if (resumeInFlight) return
resumeInFlight = true
try {
const checked = await preflightResume(candidate.record.header.id)
const hostHandoff = runtime.handoffResume
if (hostHandoff === undefined) {
const template = config.resumeCommand
const fallback = template?.replaceAll('{session}', checked.record.header.id)
await overlay.close()
resumeOverlay = undefined
appendNotice(fallback === undefined
? 'Session is resumable, but this host cannot hand it off in place.'
: `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning')
return
}
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.bold(palette.accent('Resumable sessions')), 1, 0))
const lines = sessions.map((header) => {
const when = new Date(header.createdAt).toISOString().slice(0, 16).replace('T', ' ')
const marker = header.id === agent.session.id ? palette.success(' (current)') : ''
return `${palette.muted(when)}${marker}\n ${displayText(template.replaceAll('{session}', header.id))}`
await ctx.sessions.flush(agent.session)
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)
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) {
ui.start()
ui.setFocus(editor)
appendNotice(`Resume handoff failed: ${errorChain(error)}`, '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
}
}
/** Open the current-workspace searchable session selector. */
const showResume = (): void => {
if (agent.status !== 'idle') {
appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
return
}
if (sessionQuery === undefined) {
appendNotice('Resume is not available: session query is not mounted.', 'warning')
return
}
const scan = ++resumeScan
void resumeOverlay?.close()
void sessionQuery.listSessions().then(async (records) => {
if (isDisposed() || scan !== resumeScan) return
const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd)
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers)))
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|| a.record.header.id.localeCompare(b.record.header.id))
if (isDisposed() || scan !== resumeScan) return
const session = overlayManager.open({
create: () => new ResumeDialog(
candidates,
resolved.maxResumeOptions,
palette,
(candidate) => { void handoffResume(candidate, session) },
() => { void session.close() },
),
options: {
width: resolved.resumeDialogWidth,
maxHeight: resolved.resumeDialogMaxHeight,
anchor: 'center',
margin: 1,
},
})
resumeOverlay = session
void session.closed.then(() => {
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
if (resumeOverlay === session) resumeOverlay = undefined
})
chat.addChild(new Text(lines.join('\n'), 1, 0))
requestRender()
}, (error: unknown) => {
if (!disposed && scan === resumeScan) appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
})
}
@@ -2828,6 +3156,14 @@ export function createTuiChat(
}
rebuildTranscript(true)
const restoredGoal = foldGoal(agent.session.events).goal
if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') {
appendNotice(
`Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. `
+ 'Human confirmation is required; send “继续” or run /goal resume.',
'warning',
)
}
setStatus(agent.status)
try {
ui.start()
@@ -2915,9 +3251,11 @@ export function apply(ctx: Context, config: Config): void {
// Truecolor is a terminal capability, so detect it here at the process
// boundary from COLORTERM; an explicit `truecolor` config value still wins.
const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '')
const resumeHost = ctx.get('tuiResumeHost')
mountTui(ctx, Object.assign({}, config, { truecolor }), {
terminal: new ProcessTerminal(),
exit: code => process.exit(code),
...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) },
})
}
/* v8 ignore stop */

View File

@@ -14,6 +14,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
import { TestSessionQueryService } from './session-query.ts'
interface FakeAgent extends Agent {
status: AgentStatus
@@ -48,7 +49,14 @@ export interface TuiHarnessOptions {
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
}
/** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
sessionPersistence?: { list(): Promise<SessionHeader[]> }
sessionPersistence?: {
list(): Promise<SessionHeader[]>
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
isLive?(id: ReturnType<typeof SessionId>): Promise<boolean>
}
handoffResume?: TuiRuntime['handoffResume']
/** Set false to exercise the optional session-query degradation path. */
mountSessionQuery?: boolean
}
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
@@ -118,7 +126,26 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
}
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
if (options.sessionPersistence !== undefined) {
ctx.provide('sessionPersistence', options.sessionPersistence as never)
const persistence = options.sessionPersistence
ctx.provide('sessionPersistence', {
...persistence,
locate: () => undefined,
create: () => Promise.resolve(),
append: () => Promise.resolve(),
load: persistence.load === undefined
? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
: (id: ReturnType<typeof SessionId>) => persistence.load!(id),
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() }),
isLive: persistence.isLive === undefined
? () => Promise.resolve(false)
: (id: ReturnType<typeof SessionId>) => persistence.isLive!(id),
} as never)
}
if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) {
await ctx.plugin(TestSessionQueryService)
}
const sessionId = SessionId('main-session')
const session = ctx.sessions.create(
@@ -178,6 +205,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
// test pins the clock only by passing `now` explicitly.
...(options.now === undefined ? {} : { now: options.now }),
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
...(options.handoffResume === undefined ? {} : { handoffResume: options.handoffResume }),
})
return { ctx, session, agent, terminal, exit, controller }
}

View File

@@ -14,6 +14,7 @@ describe('dsh-tui plugin export shape', () => {
expect(unwrapped.name).toBe('ui-tui')
expect(unwrapped.inject).toEqual([
'agents',
'sessions',
'commands',
'userInteraction',
'tools',

View File

@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=10 bufferRow=10
cursor hidden column=0 viewportRow=31 bufferRow=31
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
@@ -10,23 +10,60 @@ buffer
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " Resumable sessions "
style 1-18 fg=bright-blue bold
5| " 2024-01-02 03:04 (current) "
style 1-16 fg=bright-black
style 17-26 fg=green
6| " RESUME_SESSION_ID=main-session dsh "
7| " 2024-01-01 00:00 "
style 1-16 fg=bright-black
8| " RESUME_SESSION_ID=earlier-session dsh "
9| "────────────────────────────────────────────────────────────────────────────────────────────"
3| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
10| " "
4| " "
style 1-1 inverse
11| "────────────────────────────────────────────────────────────────────────────────────────────"
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
12| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
13-31| <blank>
7-8| <blank>
9| " ╭ Resume session ──────────────────────────────────────────────────────────────────────╮ "
style 2-89 fg=bright-blue
10| " │ Search: title or session id │ "
style 2-2 fg=bright-blue
style 4-10 fg=bright-black
style 12-30 dim
style 89-89 fg=bright-blue
11| " │ │ "
style 2-2 fg=bright-blue
style 89-89 fg=bright-blue
12| " │ Untitled session │ "
style 2-2 fg=bright-blue
style 4-21 fg=bright-blue bold
style 89-89 fg=bright-blue
13| " │ 2026-07-23T08:00:00.000Z · no completed turn · route unavailable │ "
style 2-2 fg=bright-blue
style 4-69 fg=bright-black
style 89-89 fg=bright-blue
14| " │ current · live · main-session │ "
style 2-2 fg=bright-blue
style 4-34 dim
style 89-89 fg=bright-blue
15| " │ unavailable: current session │ "
style 2-2 fg=bright-blue
style 4-33 fg=yellow
style 89-89 fg=bright-blue
16| " │ Resume selector design │ "
style 2-2 fg=bright-blue
style 89-89 fg=bright-blue
17| " │ 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro │ "
style 2-2 fg=bright-blue
style 4-76 fg=bright-black
style 89-89 fg=bright-blue
18| " │ persisted · earlier-session │ "
style 2-2 fg=bright-blue
style 4-32 dim
style 89-89 fg=bright-blue
19| " │ │ "
style 2-2 fg=bright-blue
style 89-89 fg=bright-blue
20| " │ Type to search • ↑/↓ navigate • Enter resume • Esc cancel │ "
style 2-2 fg=bright-blue
style 4-60 dim
style 89-89 fg=bright-blue
21| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
style 2-89 fg=bright-blue
22-31| <blank>

View File

@@ -646,13 +646,27 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('lists this workspace\'s resumable sessions with their commands', async () => {
it('opens the searchable resume selector with log-backed session summaries', async () => {
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z'))
const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }
const harness = await setupSnapshot({
config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' },
sessionPersistence: { list: async () => [
{ version: 0, id: SessionId('main-session'), createdAt: Date.parse('2024-01-02T03:04:00Z'), cwd: '/workspace/project' },
{ version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' },
] },
sessionPersistence: {
list: async () => [earlier],
load: async () => ({
meta: earlier,
events: [
{ type: 'turn/start', seq: 0, time: Date.parse('2024-01-01T00:00:01Z'), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: { content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: Date.parse('2024-01-01T00:00:03Z'), data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: Date.parse('2024-01-01T00:00:04Z'), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
{ type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: { turn: 1, step: 1, content: [{ type: 'text', text: 'ready' }], provenance: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, surfaceOp: 'append' },
{ type: 'step/end', seq: 5, time: Date.parse('2024-01-01T00:00:06Z'), data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: Date.parse('2024-01-01T00:00:07Z'), data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'session/title', seq: 7, time: Date.parse('2024-01-01T00:00:08Z'), data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },
],
}),
},
}, { columns: 92, rows: 32 })
harness.terminal.send('/resume')
harness.terminal.send('\r')
@@ -662,6 +676,7 @@ describe('TUI terminal-state snapshots', () => {
await harness.terminal.flush()
await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
dateNow.mockRestore()
})
it('pins the detailed session diagnostics card', async () => {

View File

@@ -6,8 +6,10 @@ import { Context } from 'cordis'
import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import { type LlmCallConfig } from '@deepseek-ai/dsh-llm'
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type JsonValue, type SessionHeader } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-session-title'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
@@ -152,10 +154,13 @@ describe('TUI config', () => {
maxToolOutputLines: 6,
maxQuestionOptions: 8,
maxModelOptions: 8,
maxResumeOptions: 8,
questionDialogWidth: 200,
questionDialogMaxHeight: 20,
modelDialogWidth: 72,
modelDialogMaxHeight: 20,
resumeDialogWidth: 88,
resumeDialogMaxHeight: 24,
fileSearchMaxResults: 20,
fileSearchMaxEntries: 10_000,
fileSearchExcludedDirectories: ['.git', 'node_modules'],
@@ -169,10 +174,13 @@ describe('TUI config', () => {
maxToolOutputLines: 2,
maxQuestionOptions: 3,
maxModelOptions: 4,
maxResumeOptions: 5,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
resumeDialogWidth: 84,
resumeDialogMaxHeight: 22,
fileSearchMaxResults: 7,
fileSearchMaxEntries: 123,
fileSearchExcludedDirectories: ['.git', 'generated'],
@@ -185,10 +193,13 @@ describe('TUI config', () => {
maxToolOutputLines: 2,
maxQuestionOptions: 3,
maxModelOptions: 4,
maxResumeOptions: 5,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
resumeDialogWidth: 84,
resumeDialogMaxHeight: 22,
fileSearchMaxResults: 7,
fileSearchMaxEntries: 123,
fileSearchExcludedDirectories: ['.git', 'generated'],
@@ -204,6 +215,21 @@ describe('resume command and /resume', () => {
const RESUME = 'RESUME_SESSION_ID={session} dsh'
const header = (id: string, createdAt: number, cwd: string): SessionHeader =>
({ version: 0, id: SessionId(id), createdAt, cwd })
const resumeEvents = (
title: string,
provider = 'deepseek',
time = 100,
reason: TurnEndReason = { kind: 'completed' },
): SessionEvent[] => [
{ type: 'turn/start', seq: 0, time, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: time + 1, data: { content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: time + 2, data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: time + 3, data: { header: { config: { provider, model: 'model-1' } }, reason: 'initial' } },
{ type: 'assistant/message', seq: 4, time: time + 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'done' }], provenance: { provider, model: 'model-1' } }, surfaceOp: 'append' },
{ type: 'step/end', seq: 5, time: time + 5, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } },
{ type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
]
it('prints the resume command on exit once the session is persisted', async () => {
const result = await setup({
@@ -243,73 +269,589 @@ describe('resume command and /resume', () => {
await dispose(result)
})
it('lists this workspace\'s sessions newest-first and marks the current one', async () => {
it('opens a newest-active-first searchable selector and Esc cancels without side effects', async () => {
const older = header('older-session', 500, '/workspace')
const newer = header('newer-session', 2000, '/workspace')
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
const result = await setup({
cwd: '/workspace',
config: { resumeCommand: RESUME },
handoffResume: handoff,
sessionPersistence: {
list: async () => [
header('main-session', 1000, '/workspace'),
header('older-session', 500, '/workspace'),
header('newer-session', 2000, '/workspace'),
header('foreign-session', 3000, '/elsewhere'),
],
list: async () => [older, newer, header('foreign-session', 3000, '/elsewhere')],
load: async id => id === newer.id
? { meta: newer, events: resumeEvents('Newer product work', 'deepseek', 300) }
: { meta: older, events: resumeEvents('Older investigation', 'deepseek', 100) },
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
const output = result.terminal.output
expect(output).toContain('Resume session')
expect(output).toContain('Newer product work')
expect(output).toContain('Older investigation')
expect(output).toContain('current · live')
expect(output.indexOf('Newer product work')).toBeLessThan(output.indexOf('Older investigation'))
expect(output).not.toContain('foreign-session')
result.terminal.send('Older')
await tick()
expect(result.terminal.output).toContain('Search: Older')
result.terminal.send('\x1b')
await tick()
expect(handoff).not.toHaveBeenCalled()
await dispose(result)
})
it('handles selector navigation, empty matches, and backspace search edits', async () => {
const target = header('keyboard-target', 10, '/workspace')
const result = await setup({
cwd: '/workspace',
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Keyboard target') }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[A')
result.terminal.send('\t')
result.terminal.send('zz')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('No session matches this search')
result.terminal.send('\x7f')
result.terminal.send('\x7f')
await tick()
expect(result.terminal.output).toContain('Search: title or session id')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('current session')
result.terminal.send('\x1b')
await dispose(result)
})
it('clips candidate count through the configured visible-session limit', async () => {
const targets = [header('limited-a', 10, '/workspace'), header('limited-b', 20, '/workspace')]
const result = await setup({
cwd: '/workspace',
config: { maxResumeOptions: 1 },
sessionPersistence: {
list: async () => targets,
load: async id => ({
meta: targets.find(target => target.id === id)!,
events: resumeEvents(`Limited ${id}`),
}),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('1/3')
await dispose(result)
})
it.each([
[{ kind: 'aborted' }, 'cancelled'],
[{ kind: 'error', step: 1, message: 'failed' }, 'error'],
[{ kind: 'disposed' }, 'disposed'],
[{ kind: 'max-tokens' }, 'max tokens'],
[{ kind: 'rejected', reason: 'policy' }, 'rejected'],
[{ kind: 'interrupted' }, 'interrupted'],
[{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'],
] as const)('renders the last turn result %s', async (reason, label) => {
const target = header(`turn-${label}`, 10, '/workspace')
const result = await setup({
cwd: '/workspace',
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek', 100, reason) }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain(`turn 1: ${label}`)
await dispose(result)
})
it('refuses while running instead of cancelling or switching', async () => {
const result = await setup({ cwd: '/workspace', status: 'running' })
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('finish or be cancelled first')
expect(result.agent.cancelled).toEqual([])
await dispose(result)
})
it('warns when the optional session-query service is absent', async () => {
const result = await setup({ cwd: '/workspace', mountSessionQuery: false })
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('session query is not mounted')
await dispose(result)
})
it('keeps persisted query records readable when live-lease inspection is unavailable', async () => {
const target = header('query-only-persisted', 10, '/workspace')
const result = await setup({
cwd: '/workspace',
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-only persisted session'),
}),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Query-only persisted session')
expect(result.terminal.output).toContain('persisted')
expect(result.terminal.output).not.toContain('session cannot be loaded')
await dispose(result)
})
it('contains a session-query scan failure in the current TUI', async () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: () => Promise.reject(new Error('index unavailable')),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
const output = result.terminal.output
expect(output).toContain('Resumable sessions')
expect(output).toContain('RESUME_SESSION_ID=main-session dsh')
expect(output).toContain('(current)')
expect(output).toContain('RESUME_SESSION_ID=newer-session dsh')
expect(output).not.toContain('foreign-session')
// Newest-first: the newer session's command precedes the current session's.
// Match the full resume command, not the bare id: the banner detail line
// echoes the current session id (`main-session`) above the listing.
expect(output.indexOf('RESUME_SESSION_ID=newer-session')).toBeLessThan(
output.indexOf('RESUME_SESSION_ID=main-session'),
)
expect(output.indexOf('RESUME_SESSION_ID=main-session')).toBeLessThan(
output.indexOf('RESUME_SESSION_ID=older-session'),
)
expect(result.terminal.output).toContain('Resume session scan failed: index unavailable')
expect(result.terminal.stopped).toBe(0)
await dispose(result)
})
it('warns from /resume when resume is not configured', async () => {
const result = await setup({ cwd: '/workspace' })
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Resume is not configured')
await dispose(result)
})
it('warns from /resume when no persistence backend is mounted', async () => {
const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } })
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('no persistence backend is mounted')
await dispose(result)
})
it('notes from /resume when no workspace sessions are persisted yet', async () => {
it('supersedes a slower prior selector scan', async () => {
const first = Promise.withResolvers<SessionRecord[]>()
let calls = 0
const result = await setup({
cwd: '/workspace',
config: { resumeCommand: RESUME },
sessionPersistence: { list: async () => [header('foreign-session', 10, '/elsewhere')] },
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
first.reject(new Error('superseded scan failed'))
await tick()
expect(calls).toBe(2)
expect(result.terminal.output).toContain('No matching sessions')
expect(result.terminal.output).not.toContain('superseded scan failed')
result.terminal.send('\x1b[A')
result.terminal.send('\x1b[B')
await dispose(result)
})
it('drops a selector scan that resolves after TUI disposal', async () => {
const listing = Promise.withResolvers<SessionRecord[]>()
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', { listSessions: () => listing.promise } as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('No resumable sessions found')
await dispose(result)
listing.resolve([])
await tick()
expect(result.terminal.stopped).toBeGreaterThan(0)
})
it('drops loaded selector summaries when the TUI disposed during log reads', async () => {
const target = header('dispose-during-load', 10, '/workspace')
const loading = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()
const result = await setup({
cwd: '/workspace',
sessionPersistence: {
list: async () => [target],
load: () => loading.promise,
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
await dispose(result)
loading.resolve({ meta: target, events: resumeEvents('Disposed load') })
await tick()
expect(result.terminal.stopped).toBeGreaterThan(0)
})
it('preflights route availability and occupied or 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,
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'),
}
},
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
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')
await tick()
expect(result.terminal.output).toContain('route is currently unavailable')
expect(result.terminal.stopped).toBe(0)
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')
const events = resumeEvents('Assistant route', 'deepseek')
.filter(event => event.type !== 'request/header')
.map((event, seq) => ({ ...event, seq })) as SessionEvent[]
const result = await setup({
cwd: '/workspace',
sessionPersistence: {
list: async () => [assistantOnly, empty],
load: async id => id === assistantOnly.id
? { meta: assistantOnly, events }
: { meta: empty, events: [] },
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('deepseek/model-1')
expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString())
await dispose(result)
})
it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => {
const target = header('target-session', 10, '/workspace')
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => Promise.reject(new Error('test host retained process')))
const result = await setup({
cwd: '/workspace',
handoffResume: handoff,
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Target session') }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Target session')
result.terminal.send('\r')
await tick(); await tick()
expect(handoff).toHaveBeenCalledTimes(1)
expect(handoff).toHaveBeenCalledWith(target.id)
expect(result.terminal.stopped).toBeGreaterThan(0)
expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
await dispose(result)
})
it('restores the UI when a host returns instead of replacing the process', async () => {
const target = header('returning-host', 10, '/workspace')
const result = await setup({
cwd: '/workspace',
handoffResume: async () => undefined as never,
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Returning host') }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Returning host')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('resume host returned without replacing the process')
await dispose(result)
})
it('keeps the current TUI when the selected log fails its second preflight load', async () => {
const target = header('racing-corruption', 10, '/workspace')
let loads = 0
const result = await setup({
cwd: '/workspace',
handoffResume: vi.fn(),
sessionPersistence: {
list: async () => [target],
load: async () => {
if (++loads > 1) throw new Error('log changed during selection')
return { meta: target, events: resumeEvents('Racing corruption') }
},
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Racing corruption')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Resume failed: session cannot be loaded: failed to inspect session')
expect(result.terminal.output).toContain('log changed during selection')
expect(result.terminal.stopped).toBe(0)
await dispose(result)
})
it('rejects a candidate whose cwd changes between listing and preflight', async () => {
const target = header('moving-workspace', 10, '/workspace')
let listings = 0
const result = await setup({
cwd: '/workspace',
handoffResume: vi.fn(),
sessionPersistence: {
list: async () => [++listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere')],
load: async () => ({
meta: listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere'),
events: resumeEvents('Moving workspace'),
}),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Moving workspace')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('different workspace')
await dispose(result)
})
it('admits only one handoff while the selected preflight is pending', async () => {
const target = header('single-handoff', 10, '/workspace')
const preflight = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()
let loads = 0
const result = await setup({
cwd: '/workspace',
sessionPersistence: {
list: async () => [target],
load: () => ++loads === 1
? Promise.resolve({ meta: target, events: resumeEvents('Single handoff') })
: preflight.promise,
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Single handoff')
result.terminal.send('\r')
result.terminal.send('\r')
await tick()
preflight.resolve({ meta: target, events: resumeEvents('Single handoff') })
await tick(); await tick()
expect(loads).toBe(2)
await dispose(result)
})
it('rechecks running state and candidate existence before loading the selected log', async () => {
const target = header('preflight-races', 10, '/workspace')
const result = await setup({
cwd: '/workspace',
handoffResume: vi.fn(),
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Preflight races') }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.agent.status = 'running'
result.terminal.send('Preflight races')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)')
result.agent.status = 'idle'
await dispose(result)
let disappearingLists = 0
const disappearing = await setup({
cwd: '/workspace',
handoffResume: vi.fn(),
sessionPersistence: {
list: async () => ++disappearingLists <= 2 ? [target] : [],
load: async () => ({ meta: target, events: resumeEvents('Disappearing target') }),
},
})
disappearing.terminal.send('/resume')
disappearing.terminal.send('\r')
await tick(); await tick()
disappearing.terminal.send('Disappearing target')
disappearing.terminal.send('\r')
await tick()
expect(disappearing.terminal.output).toContain('is no longer available')
await dispose(disappearing)
})
it('rechecks idleness after the selected log finishes loading', async () => {
const target = header('load-turns-running', 10, '/workspace')
let loads = 0
const result = await setup({
cwd: '/workspace',
handoffResume: vi.fn(),
sessionPersistence: {
list: async () => [target],
load: async () => {
loads += 1
if (loads === 2) result.agent.status = 'running'
return { meta: target, events: resumeEvents('Load turns running') }
},
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Load turns running')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)')
result.agent.status = 'idle'
await dispose(result)
})
it('keeps resumeCommand as a displayed fallback when the host cannot hand off', async () => {
const target = header('fallback-session', 10, '/workspace')
const result = await setup({
cwd: '/workspace',
config: { resumeCommand: RESUME },
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Fallback target') }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Fallback target')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:')
expect(result.terminal.output).toContain('RESUME_SESSION_ID=fallback-session')
expect(result.terminal.stopped).toBe(0)
await dispose(result)
})
it('keeps the selector independent from an absent command fallback', async () => {
const target = header('no-fallback-session', 10, '/workspace')
const result = await setup({
cwd: '/workspace',
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('No fallback target') }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('No fallback target')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Session is resumable, but this host cannot hand it off in place')
await dispose(result)
})
it('rechecks idleness after the current-session flush', async () => {
const target = header('post-flush-running', 10, '/workspace')
const control: { setRunning?: () => void } = {}
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', () => { control.setRunning?.() })
},
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Post-flush running') }),
},
})
control.setRunning = () => { result.agent.status = 'running' }
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('Post-flush running')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)')
expect(handoff).not.toHaveBeenCalled()
result.agent.status = 'idle'
await dispose(result)
})
})
describe('pi-tui chat lifecycle and transcript', () => {
it('restores durable goal phase without implying automatic continuation', async () => {
const change: GoalSnapshotChangeMeta = {
kind: 'goal/change',
version: GOAL_CHANGE_VERSION,
operation: 'create',
goal: {
id: GoalId('restored-goal'),
revision: 1,
objective: 'Resume only with human confirmation',
phase: 'active',
maxGoalRounds: 4,
},
roundsStarted: 0,
createdAt: 10,
updatedAt: 10,
}
const result = await setup({
beforeMount(session) {
session.append('context/message', {
content: renderGoalChange(change),
source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 },
meta: change as unknown as JsonValue,
}, { surfaceOp: 'append' })
},
})
expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed')
expect(result.terminal.output).toContain('/goal resume')
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('goal active')
await dispose(result)
})
it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
const result = await setup({
// A fixed short cwd keeps the footer's token counters inside the 88-column

View File

@@ -20,6 +20,9 @@
{
"path": "../../core/agent-loop"
},
{
"path": "../../goal/goal"
},
{
"path": "../../core/session"
},
@@ -29,6 +32,9 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-title/session-title"
},