mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
/**
|
|
* Owner-scoped persistent PTY registry. Backends own terminal mechanics while
|
|
* this service owns ids, publication, authorization, and awaited cleanup.
|
|
* @module @deepseek-ai/dsh-pty
|
|
*/
|
|
|
|
import { Context, Service } from 'cordis'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import { PtyBackendCleanupError } from './types.ts'
|
|
import type {
|
|
PtyBackend,
|
|
PtyBackendSession,
|
|
PtyReadRequest,
|
|
PtyReadResult,
|
|
PtySendOperation,
|
|
PtySendRequest,
|
|
PtySessionIdValue,
|
|
PtySessionSnapshot,
|
|
PtySignal,
|
|
PtySignalResult,
|
|
PtySpawnRequest,
|
|
PtySpawnResult,
|
|
} from './types.ts'
|
|
|
|
export type {
|
|
PtyBackend,
|
|
PtyBackendSession,
|
|
PtyBackendSpawnSpec,
|
|
PtyReadRequest,
|
|
PtyReadResult,
|
|
PtySendOperation,
|
|
PtySendRead,
|
|
PtySendRequest,
|
|
PtySendResult,
|
|
PtySessionSnapshot,
|
|
PtySessionStatus,
|
|
PtySignal,
|
|
PtySignalResult,
|
|
PtySpawnRequest,
|
|
PtySpawnResult,
|
|
PtyWaitReason,
|
|
} from './types.ts'
|
|
export { PtyBackendCleanupError } from './types.ts'
|
|
|
|
/** Opaque identity minted by {@link PtyService} for one live PTY session. */
|
|
export type PtySessionId = PtySessionIdValue
|
|
|
|
declare module 'cordis' {
|
|
interface Context {
|
|
pty: PtyService
|
|
}
|
|
}
|
|
|
|
/** Machine-routable PTY service failures. */
|
|
export type PtyErrorCode =
|
|
| 'DUPLICATE_BACKEND'
|
|
| 'DUPLICATE_NAME'
|
|
| 'FOREIGN_SESSION'
|
|
| 'NO_BACKEND'
|
|
| 'NO_SESSION'
|
|
| 'OWNER_NOT_LIVE'
|
|
| 'SEND_ACTIVE'
|
|
| 'SERVICE_DISPOSING'
|
|
|
|
/** Error carrying a stable {@link PtyErrorCode}. */
|
|
export class PtyError extends Error {
|
|
constructor(message: string, readonly code: PtyErrorCode) {
|
|
super(message)
|
|
this.name = 'PtyError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Brand one registry-minted string as a {@link PtySessionId}.
|
|
* @param value - raw registry-issued id.
|
|
* @returns Same string with the PTY session brand.
|
|
*/
|
|
export function PtySessionId(value: string): PtySessionId {
|
|
return value as PtySessionId
|
|
}
|
|
|
|
interface SessionRecord {
|
|
readonly id: PtySessionId
|
|
readonly owner: Agent
|
|
readonly name: string | undefined
|
|
readonly type: string
|
|
readonly session: PtyBackendSession
|
|
active: PtySendOperation | undefined
|
|
closing: Promise<void> | undefined
|
|
}
|
|
|
|
interface PendingSpawn {
|
|
readonly owner: Agent
|
|
readonly controller: AbortController
|
|
readonly settled: Promise<void>
|
|
cleanupFailure: { error: unknown } | undefined
|
|
}
|
|
|
|
interface SpawnReservation {
|
|
readonly signal: AbortSignal
|
|
release(cleanupFailure: { error: unknown } | undefined): void
|
|
}
|
|
|
|
/** In-process registry for replaceable PTY backends and exact-Agent sessions. */
|
|
export class PtyService extends Service {
|
|
private readonly backends = new Map<string, PtyBackend>()
|
|
private readonly sessions = new Map<PtySessionId, SessionRecord>()
|
|
private readonly reservedNames = new Map<Agent, Set<string>>()
|
|
private readonly pendingSpawns = new Map<Agent, Set<PendingSpawn>>()
|
|
private readonly ownerCleanups = new Map<Agent, () => Promise<void> | void>()
|
|
private readonly disposedOwners = new WeakSet<Agent>()
|
|
private nextId = 0
|
|
private disposing = false
|
|
|
|
constructor(ctx: Context) {
|
|
super(ctx, 'pty')
|
|
ctx.effect(() => () => this.disposeAll(), 'pty teardown')
|
|
}
|
|
|
|
/**
|
|
* Register one backend type for this effect scope.
|
|
* @param backend - provider with a non-empty unique type.
|
|
* @returns disposer that removes exactly this contribution.
|
|
*/
|
|
registerBackend(backend: PtyBackend): () => void {
|
|
if (backend.type.length === 0) throw new Error('pty backend type must be non-empty')
|
|
if (this.backends.has(backend.type)) {
|
|
throw new PtyError(`a PTY backend named "${backend.type}" is already registered`, 'DUPLICATE_BACKEND')
|
|
}
|
|
const dispose = this.ctx.effect(() => {
|
|
this.backends.set(backend.type, backend)
|
|
return () => {
|
|
if (this.backends.get(backend.type) === backend) this.backends.delete(backend.type)
|
|
}
|
|
}, 'pty.registerBackend()')
|
|
return () => void dispose()
|
|
}
|
|
|
|
/**
|
|
* List registered backend types in registration order.
|
|
* @returns fresh backend type names.
|
|
*/
|
|
listBackends(): string[] {
|
|
return [...this.backends.keys()]
|
|
}
|
|
|
|
/**
|
|
* Create and publish one owner-scoped session after backend setup succeeds.
|
|
* @param owner - exact registered Agent that owns access and cleanup.
|
|
* @param request - backend type plus optional owner-local name and cwd.
|
|
* @param signal - cancellation of unpublished setup.
|
|
* @returns published identity, metadata, status, and MOTD.
|
|
*/
|
|
async spawn(owner: Agent, request: PtySpawnRequest, signal?: AbortSignal): Promise<PtySpawnResult> {
|
|
this.assertActive()
|
|
signal?.throwIfAborted()
|
|
this.ensureOwnerCleanup(owner)
|
|
const backend = this.backends.get(request.type)
|
|
if (backend === undefined) throw new PtyError(`no PTY backend registered for "${request.type}"`, 'NO_BACKEND')
|
|
if (request.name !== undefined && request.name.length === 0) throw new Error('PTY session name must be non-empty')
|
|
const releaseName = this.reserveName(owner, request.name)
|
|
const spawnReservation = this.reserveSpawn(owner)
|
|
const backendSignal = signal === undefined
|
|
? spawnReservation.signal
|
|
: AbortSignal.any([signal, spawnReservation.signal])
|
|
const sessionId = PtySessionId(`pty-${++this.nextId}`)
|
|
let session: PtyBackendSession | undefined
|
|
let cleanupFailure: { error: unknown } | undefined
|
|
try {
|
|
session = await backend.spawn({
|
|
sessionId,
|
|
owner,
|
|
type: request.type,
|
|
...request.name !== undefined ? { name: request.name } : {},
|
|
...request.cwd !== undefined ? { cwd: request.cwd } : {},
|
|
signal: backendSignal,
|
|
})
|
|
signal?.throwIfAborted()
|
|
if (this.disposing) {
|
|
throw new PtyError('PTY service is disposing', 'SERVICE_DISPOSING')
|
|
}
|
|
if (!this.isLiveOwner(owner)) {
|
|
throw new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE')
|
|
}
|
|
const record: SessionRecord = {
|
|
id: sessionId,
|
|
owner,
|
|
name: request.name,
|
|
type: request.type,
|
|
session,
|
|
active: undefined,
|
|
closing: undefined,
|
|
}
|
|
this.sessions.set(sessionId, record)
|
|
return this.snapshot(record, session.motd)
|
|
} catch (error) {
|
|
if (error instanceof PtyBackendCleanupError) {
|
|
cleanupFailure = { error: error.cleanupError }
|
|
}
|
|
let rollbackFailure: { error: unknown } | undefined
|
|
if (session !== undefined && !this.sessions.has(sessionId)) {
|
|
try {
|
|
await session.close('PTY spawn rolled back')
|
|
} catch (closeError: unknown) {
|
|
rollbackFailure = { error: closeError }
|
|
cleanupFailure = rollbackFailure
|
|
}
|
|
}
|
|
let failure: unknown = error
|
|
try {
|
|
signal?.throwIfAborted()
|
|
spawnReservation.signal.throwIfAborted()
|
|
} catch (cancellation: unknown) {
|
|
failure = cancellation
|
|
}
|
|
if (rollbackFailure !== undefined && signal?.aborted !== true) {
|
|
throw new AggregateError([failure, rollbackFailure.error], 'PTY spawn and rollback both failed')
|
|
}
|
|
throw failure
|
|
} finally {
|
|
spawnReservation.release(cleanupFailure)
|
|
releaseName()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test whether an exact owner has a published session or unpublished spawn.
|
|
* @param owner - exact live owner to inspect.
|
|
* @returns true across the entire spawn-to-close interval, with no publication gap.
|
|
*/
|
|
hasOwnerActivity(owner: Agent): boolean {
|
|
return (this.pendingSpawns.get(owner)?.size ?? 0) > 0
|
|
|| [...this.sessions.values()].some(record => record.owner === owner)
|
|
}
|
|
|
|
/**
|
|
* Start one exclusive interactive send.
|
|
* @param owner - exact session owner.
|
|
* @param id - target PTY identity.
|
|
* @param request - explicit text, submit behavior, and cancellation.
|
|
* @returns live operation handle for foreground await or task registration.
|
|
*/
|
|
startSend(owner: Agent, id: PtySessionId, request: PtySendRequest): PtySendOperation {
|
|
const record = this.expectOwned(owner, id)
|
|
if (record.closing !== undefined) throw new Error(`PTY session ${id} is closing`)
|
|
if (record.active !== undefined) throw new PtyError(`PTY session ${id} already has an active send`, 'SEND_ACTIVE')
|
|
const operation = record.session.startSend(request)
|
|
record.active = operation
|
|
void operation.done.then(
|
|
() => { record.active = undefined },
|
|
() => { record.active = undefined },
|
|
)
|
|
return operation
|
|
}
|
|
|
|
/**
|
|
* Read one bounded scrollback page from an owned session.
|
|
* @param owner - exact session owner.
|
|
* @param id - target PTY identity.
|
|
* @param request - optional newest-relative offset and line count.
|
|
* @returns bounded retained text and pagination metadata.
|
|
*/
|
|
read(owner: Agent, id: PtySessionId, request: PtyReadRequest = {}): PtyReadResult {
|
|
return this.expectOwned(owner, id).session.read(request)
|
|
}
|
|
|
|
/**
|
|
* Deliver an allowed signal through an owned backend session.
|
|
* @param owner - exact session owner.
|
|
* @param id - target PTY identity.
|
|
* @param signal - allowed POSIX signal name.
|
|
* @returns delivered foreground process-group identity.
|
|
*/
|
|
signal(owner: Agent, id: PtySessionId, signal: PtySignal): Promise<PtySignalResult> {
|
|
return this.expectOwned(owner, id).session.signal(signal)
|
|
}
|
|
|
|
/**
|
|
* Close one owned session and remove it only after quiescent backend cleanup.
|
|
* @param owner - exact session owner.
|
|
* @param id - target PTY identity.
|
|
* @param reason - diagnostic cleanup reason.
|
|
* @returns true for a newly closed session, false when the same close is already in flight.
|
|
*/
|
|
async kill(owner: Agent, id: PtySessionId, reason = 'model request'): Promise<boolean> {
|
|
const record = this.expectOwned(owner, id)
|
|
if (record.closing !== undefined) {
|
|
await record.closing
|
|
return false
|
|
}
|
|
const closing = record.session.close(reason)
|
|
record.closing = closing
|
|
try {
|
|
await closing
|
|
this.sessions.delete(id)
|
|
return true
|
|
} catch (error) {
|
|
record.closing = undefined
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List fresh snapshots for exactly one owner.
|
|
* @param owner - exact owner whose sessions are visible.
|
|
* @returns owner-visible snapshots in publication order.
|
|
*/
|
|
list(owner: Agent): PtySessionSnapshot[] {
|
|
return [...this.sessions.values()]
|
|
.filter(record => record.owner === owner)
|
|
.map(record => this.snapshot(record))
|
|
}
|
|
|
|
private assertActive(): void {
|
|
if (this.disposing) throw new PtyError('PTY service is disposing', 'SERVICE_DISPOSING')
|
|
}
|
|
|
|
private isLiveOwner(owner: Agent): boolean {
|
|
return !this.disposedOwners.has(owner) && this.ctx.get('agents')?.get(owner.id) === owner
|
|
}
|
|
|
|
private ensureOwnerCleanup(owner: Agent): void {
|
|
if (!this.isLiveOwner(owner)) {
|
|
throw new PtyError(`agent "${owner.id}" is not the registered PTY owner`, 'OWNER_NOT_LIVE')
|
|
}
|
|
if (this.ownerCleanups.has(owner)) return
|
|
const detach = owner.ctx.effect(() => async () => {
|
|
this.disposedOwners.add(owner)
|
|
this.ownerCleanups.delete(owner)
|
|
await this.disposeOwned(owner)
|
|
}, 'pty.ownerCleanup()')
|
|
this.ownerCleanups.set(owner, detach)
|
|
}
|
|
|
|
private reserveName(owner: Agent, name: string | undefined): () => void {
|
|
if (name === undefined) return () => {}
|
|
if ([...this.sessions.values()].some(record => record.owner === owner && record.name === name)) {
|
|
throw new PtyError(`PTY session name "${name}" already exists for this owner`, 'DUPLICATE_NAME')
|
|
}
|
|
const reserved = this.reservedNames.get(owner) ?? new Set<string>()
|
|
if (reserved.has(name)) throw new PtyError(`PTY session name "${name}" is already being created`, 'DUPLICATE_NAME')
|
|
reserved.add(name)
|
|
this.reservedNames.set(owner, reserved)
|
|
return () => {
|
|
reserved.delete(name)
|
|
if (reserved.size === 0) this.reservedNames.delete(owner)
|
|
}
|
|
}
|
|
|
|
private reserveSpawn(owner: Agent): SpawnReservation {
|
|
const controller = new AbortController()
|
|
const settlement = Promise.withResolvers<void>()
|
|
const pending: PendingSpawn = { owner, controller, settled: settlement.promise, cleanupFailure: undefined }
|
|
const owned = this.pendingSpawns.get(owner) ?? new Set<PendingSpawn>()
|
|
owned.add(pending)
|
|
this.pendingSpawns.set(owner, owned)
|
|
return {
|
|
signal: controller.signal,
|
|
release: (cleanupFailure) => {
|
|
pending.cleanupFailure = cleanupFailure
|
|
if (cleanupFailure === undefined) this.removePendingSpawn(pending)
|
|
settlement.resolve()
|
|
},
|
|
}
|
|
}
|
|
|
|
private removePendingSpawn(pending: PendingSpawn): void {
|
|
const owned = this.pendingSpawns.get(pending.owner)
|
|
if (owned === undefined) return
|
|
owned.delete(pending)
|
|
if (owned.size === 0) this.pendingSpawns.delete(pending.owner)
|
|
}
|
|
|
|
private async abortPendingSpawns(owner: Agent | undefined, reason: PtyError): Promise<void> {
|
|
const pending = owner === undefined
|
|
? [...this.pendingSpawns.values()].flatMap(owned => [...owned])
|
|
: [...(this.pendingSpawns.get(owner) ?? [])]
|
|
for (const spawn of pending) spawn.controller.abort(reason)
|
|
await Promise.all(pending.map(spawn => spawn.settled))
|
|
const failures = pending.flatMap(spawn => spawn.cleanupFailure === undefined ? [] : [spawn.cleanupFailure.error])
|
|
for (const spawn of pending) this.removePendingSpawn(spawn)
|
|
if (failures.length > 0) {
|
|
throw new AggregateError(failures, 'failed to roll back unpublished PTY setup')
|
|
}
|
|
}
|
|
|
|
private expectOwned(owner: Agent, id: PtySessionId): SessionRecord {
|
|
const record = this.sessions.get(id)
|
|
if (record === undefined) throw new PtyError(`unknown PTY session ${id}`, 'NO_SESSION')
|
|
if (record.owner !== owner) throw new PtyError(`PTY session ${id} belongs to another agent`, 'FOREIGN_SESSION')
|
|
return record
|
|
}
|
|
|
|
private snapshot(record: SessionRecord): PtySessionSnapshot
|
|
private snapshot(record: SessionRecord, motd: string): PtySpawnResult
|
|
private snapshot(record: SessionRecord, motd?: string): PtySpawnResult | PtySessionSnapshot {
|
|
return {
|
|
sessionId: record.id,
|
|
...record.name !== undefined ? { name: record.name } : {},
|
|
type: record.type,
|
|
...record.session.pid !== undefined ? { pid: record.session.pid } : {},
|
|
status: record.session.status(),
|
|
...motd !== undefined ? { motd } : {},
|
|
}
|
|
}
|
|
|
|
private async abortAndClose(owner: Agent | undefined, abortReason: PtyError, closeReason: string): Promise<void> {
|
|
const failures: unknown[] = []
|
|
try {
|
|
await this.abortPendingSpawns(owner, abortReason)
|
|
} catch (error: unknown) {
|
|
failures.push(error)
|
|
}
|
|
const records = [...this.sessions.values()].filter(record => owner === undefined || record.owner === owner)
|
|
try {
|
|
await this.closeRecords(records, closeReason)
|
|
} catch (error: unknown) {
|
|
failures.push(error)
|
|
}
|
|
if (failures.length > 0) throw new AggregateError(failures, 'failed to clean up PTY lifecycle')
|
|
}
|
|
|
|
private async disposeOwned(owner: Agent): Promise<void> {
|
|
try {
|
|
await this.abortAndClose(
|
|
owner,
|
|
new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE'),
|
|
'PTY owner disposed',
|
|
)
|
|
} finally {
|
|
this.reservedNames.delete(owner)
|
|
}
|
|
}
|
|
|
|
private async disposeAll(): Promise<void> {
|
|
this.disposing = true
|
|
// Teardown is best-effort: a close failure still clears registries and runs
|
|
// owner cleanups before the aggregated error propagates, so one stuck
|
|
// session cannot orphan backends, reservations, or owner detachers.
|
|
try {
|
|
await this.abortAndClose(
|
|
undefined,
|
|
new PtyError('PTY service is disposing', 'SERVICE_DISPOSING'),
|
|
'PTY service disposed',
|
|
)
|
|
} finally {
|
|
this.backends.clear()
|
|
this.reservedNames.clear()
|
|
this.pendingSpawns.clear()
|
|
const cleanups = [...this.ownerCleanups.values()]
|
|
this.ownerCleanups.clear()
|
|
await Promise.all(cleanups.map(cleanup => Promise.resolve(cleanup())))
|
|
}
|
|
}
|
|
|
|
private async closeRecords(records: SessionRecord[], reason: string): Promise<void> {
|
|
const results = await Promise.allSettled(records.map(async (record) => {
|
|
const closing = record.closing ?? record.session.close(reason)
|
|
record.closing = closing
|
|
try {
|
|
await closing
|
|
this.sessions.delete(record.id)
|
|
} catch (error: unknown) {
|
|
// A concurrent retry may already own a newer fence; never clear it.
|
|
if (record.closing === closing) record.closing = undefined
|
|
throw error
|
|
}
|
|
}))
|
|
const failures = results
|
|
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
|
.map<unknown>(result => result.reason as unknown)
|
|
if (failures.length > 0) throw new AggregateError(failures, `failed to close ${failures.length} PTY session(s)`)
|
|
}
|
|
}
|
|
|
|
export default PtyService
|