fix(pty): surface pending rollback failures
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-16-persistent-pty-sessions.md: a73b8d0247243cc0d9cc93f27d84a8540b3b271d
|
||||
2026-07-16-persistent-pty-sessions.zh.md: b0dc4010f69206b7aaeab4164199ffd935165955
|
||||
2026-07-16-persistent-pty-sessions.md: db161a865f1c30fc3979e1a92dc6d427ecb58f25
|
||||
2026-07-16-persistent-pty-sessions.zh.md: 06c902437aee2e5725e1765840dce03ae15c30a4
|
||||
|
||||
@@ -34,7 +34,7 @@ Idle detection is backend behavior, not a second public seam. A remote or contai
|
||||
|
||||
There are no plugin-load auto-start sessions. `terminal_open` creates a session only during an agent tool call, when ownership and the owning event-sourced session are known. A future declarative startup feature must compose through unpublished agent setup rather than create shared global terminals.
|
||||
|
||||
Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Unpublished backend setup is a tracked lifecycle operation: owner or service disposal aborts its service-owned signal, waits for backend settlement and rollback, and only then returns. Caller cancellation retains its exact `AbortSignal.reason` even when the backend rejects in response. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md). The service reserves the session synchronously for one active send before returning its operation, including before a background task id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation cannot cross operation ownership.
|
||||
Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Unpublished backend setup is a tracked lifecycle operation: owner or service disposal aborts its service-owned signal, waits for backend settlement and rollback, and only then returns. Caller cancellation retains its exact `AbortSignal.reason` even when the backend rejects in response; a rollback close failure rejects both the spawn and the disposing lifecycle. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md). The service reserves the session synchronously for one active send before returning its operation, including before a background task id becomes visible; a second send fails with `SEND_ACTIVE`, so output and cancellation cannot cross operation ownership.
|
||||
|
||||
### Security and process boundary
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ idle 检测属于后端行为,不是第二条公共 seam。远程或容器后
|
||||
|
||||
实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。
|
||||
|
||||
agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。未发布的后端 setup 同样是受追踪的生命周期操作:owner 或服务 dispose 会中止服务自有的 signal,等待后端结算与回滚完成后才返回。即使后端响应取消而 reject,调用方取消仍原样保留其 `AbortSignal.reason`。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。
|
||||
agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。未发布的后端 setup 同样是受追踪的生命周期操作:owner 或服务 dispose 会中止服务自有的 signal,等待后端结算与回滚完成后才返回。即使后端响应取消而 reject,调用方取消仍原样保留其 `AbortSignal.reason`;若回滚 close 失败,spawn 与正在执行的 lifecycle dispose 都会 reject。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。
|
||||
|
||||
### 安全与进程边界
|
||||
|
||||
|
||||
@@ -841,7 +841,7 @@ list(owner: Agent): PtySessionSnapshot[]
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [PtyBackend](../core-data-structures/pty.md) · [PtyReadRequest](../core-data-structures/pty.md) · [PtyReadResult](../core-data-structures/pty.md) · [PtySendOperation](../core-data-structures/pty.md) · [PtySendRequest](../core-data-structures/pty.md) · [PtySessionId](../core-data-structures/pty.md) · [PtySessionSnapshot](../core-data-structures/pty.md) · [PtySignal](../core-data-structures/pty.md) · [PtySignalResult](../core-data-structures/pty.md) · [PtySpawnRequest](../core-data-structures/pty.md) · [PtySpawnResult](../core-data-structures/pty.md)
|
||||
|
||||
Source: [`packages/pty/pty/src/index.ts:101`](../../packages/pty/pty/src/index.ts)
|
||||
Source: [`packages/pty/pty/src/index.ts:102`](../../packages/pty/pty/src/index.ts)
|
||||
|
||||
## `ctx.sandbox` — `SandboxProvider` (abstract seam)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Owner-scoped persistent PTY seam. `PtyService` registers as `ctx.pty`, mints opa
|
||||
- Backends register one stable `type` and return an unpublished `PtyBackendSession`; failed or cancelled setup must clean partial resources.
|
||||
- Spawn cancellation preserves the caller's exact abort reason. Service disposal and owner loss remain distinct machine-routable failures after backend setup.
|
||||
- Owner and service disposal abort unpublished setup through a service-owned signal and await backend settlement plus rollback before returning.
|
||||
- A rollback close failure rejects both the spawn and the disposing lifecycle instead of claiming quiescence.
|
||||
- `hasOwnerActivity(owner)` spans unpublished setup through final close, so lifecycle policy can fence the exact owner without a publication race.
|
||||
- A successful spawn publishes one `PtySessionId`. The optional `name` is owner-local display metadata, never authority.
|
||||
- One session accepts at most one live send operation. Reads and signals may observe it; another send fails until the operation settles.
|
||||
|
||||
@@ -90,11 +90,12 @@ interface SessionRecord {
|
||||
interface PendingSpawn {
|
||||
readonly controller: AbortController
|
||||
readonly settled: Promise<void>
|
||||
rollbackFailure: { error: unknown } | undefined
|
||||
}
|
||||
|
||||
interface SpawnReservation {
|
||||
readonly signal: AbortSignal
|
||||
release(): void
|
||||
release(rollbackFailure: { error: unknown } | undefined): void
|
||||
}
|
||||
|
||||
/** In-process registry for replaceable PTY backends and exact-Agent sessions. */
|
||||
@@ -161,6 +162,7 @@ export class PtyService extends Service {
|
||||
: AbortSignal.any([signal, spawnReservation.signal])
|
||||
const sessionId = PtySessionId(`pty-${++this.nextId}`)
|
||||
let session: PtyBackendSession | undefined
|
||||
let rollbackFailure: { error: unknown } | undefined
|
||||
try {
|
||||
session = await backend.spawn({
|
||||
sessionId,
|
||||
@@ -189,7 +191,6 @@ export class PtyService extends Service {
|
||||
this.sessions.set(sessionId, record)
|
||||
return this.snapshot(record, session.motd)
|
||||
} catch (error) {
|
||||
let rollbackFailure: { error: unknown } | undefined
|
||||
if (session !== undefined && !this.sessions.has(sessionId)) {
|
||||
try {
|
||||
await session.close('PTY spawn rolled back')
|
||||
@@ -209,7 +210,7 @@ export class PtyService extends Service {
|
||||
}
|
||||
throw failure
|
||||
} finally {
|
||||
spawnReservation.release()
|
||||
spawnReservation.release(rollbackFailure)
|
||||
releaseName()
|
||||
}
|
||||
}
|
||||
@@ -341,13 +342,14 @@ export class PtyService extends Service {
|
||||
private reserveSpawn(owner: Agent): SpawnReservation {
|
||||
const controller = new AbortController()
|
||||
const settlement = Promise.withResolvers<void>()
|
||||
const pending: PendingSpawn = { controller, settled: settlement.promise }
|
||||
const pending: PendingSpawn = { controller, settled: settlement.promise, rollbackFailure: undefined }
|
||||
const owned = this.pendingSpawns.get(owner) ?? new Set<PendingSpawn>()
|
||||
owned.add(pending)
|
||||
this.pendingSpawns.set(owner, owned)
|
||||
return {
|
||||
signal: controller.signal,
|
||||
release: () => {
|
||||
release: (rollbackFailure) => {
|
||||
pending.rollbackFailure = rollbackFailure
|
||||
owned.delete(pending)
|
||||
if (owned.size === 0) this.pendingSpawns.delete(owner)
|
||||
settlement.resolve()
|
||||
@@ -361,6 +363,10 @@ export class PtyService extends Service {
|
||||
: [...(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.rollbackFailure === undefined ? [] : [spawn.rollbackFailure.error])
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'failed to roll back unpublished PTY setup')
|
||||
}
|
||||
}
|
||||
|
||||
private expectOwned(owner: Agent, id: PtySessionId): SessionRecord {
|
||||
@@ -383,11 +389,32 @@ export class PtyService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
await this.abortPendingSpawns(owner, new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE'))
|
||||
const owned = [...this.sessions.values()].filter(record => record.owner === owner)
|
||||
await this.closeRecords(owned, 'PTY owner disposed')
|
||||
this.reservedNames.delete(owner)
|
||||
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> {
|
||||
@@ -396,9 +423,11 @@ export class PtyService extends Service {
|
||||
// owner cleanups before the aggregated error propagates, so one stuck
|
||||
// session cannot orphan backends, reservations, or owner detachers.
|
||||
try {
|
||||
await this.abortPendingSpawns(undefined, new PtyError('PTY service is disposing', 'SERVICE_DISPOSING'))
|
||||
const records = [...this.sessions.values()]
|
||||
await this.closeRecords(records, 'PTY service disposed')
|
||||
await this.abortAndClose(
|
||||
undefined,
|
||||
new PtyError('PTY service is disposing', 'SERVICE_DISPOSING'),
|
||||
'PTY service disposed',
|
||||
)
|
||||
} finally {
|
||||
this.backends.clear()
|
||||
this.reservedNames.clear()
|
||||
|
||||
@@ -299,6 +299,26 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
||||
})
|
||||
|
||||
it('reports unpublished rollback failure through service disposal', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<PtyBackendSession>()
|
||||
const session = new StubSession()
|
||||
session.rejectClose = true
|
||||
ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise })
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
|
||||
const pending = ctx.pty.spawn(owner, { type: 'slow' })
|
||||
const pendingFailure = expect(pending).rejects.toThrow('PTY spawn and rollback both failed')
|
||||
const internal = ctx.pty as unknown as { disposeAll(): Promise<void> }
|
||||
const disposalFailure = expect(internal.disposeAll()).rejects.toThrow('failed to clean up PTY lifecycle')
|
||||
gate.resolve(session)
|
||||
|
||||
await pendingFailure
|
||||
await disposalFailure
|
||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
||||
})
|
||||
|
||||
it('keeps independent reservations and handles provider failure before publication', async () => {
|
||||
const ctx = await harness()
|
||||
const firstGate = Promise.withResolvers<PtyBackendSession>()
|
||||
@@ -343,11 +363,16 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
const failedSpawn = new StubSession()
|
||||
failedSpawn.rejectClose = true
|
||||
let ownerDisposal = Promise.resolve()
|
||||
const internal = ctx.pty as unknown as {
|
||||
disposedOwners: WeakSet<Agent>
|
||||
disposeOwned(owner: Agent): Promise<void>
|
||||
}
|
||||
ctx.pty.registerBackend({
|
||||
type: 'bad-spawn',
|
||||
async spawn({ signal }) {
|
||||
if (signal === undefined) throw new Error('missing spawn signal')
|
||||
ownerDisposal = disposeAgentScope(owner)
|
||||
internal.disposedOwners.add(owner)
|
||||
ownerDisposal = internal.disposeOwned(owner)
|
||||
if (!signal.aborted) {
|
||||
await new Promise<undefined>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
|
||||
@@ -357,7 +382,7 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
},
|
||||
})
|
||||
await expect(ctx.pty.spawn(owner, { type: 'bad-spawn' })).rejects.toThrow('spawn and rollback both failed')
|
||||
await ownerDisposal
|
||||
await expect(ownerDisposal).rejects.toThrow('failed to clean up PTY lifecycle')
|
||||
|
||||
const nextOwner = stubAgent(ctx, 'next')
|
||||
ctx.agents.register(nextOwner)
|
||||
@@ -456,7 +481,7 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
}
|
||||
// Teardown surfaces the close failure, but its finally still clears the
|
||||
// backend and owner-cleanup registries instead of orphaning them.
|
||||
await expect(internal.disposeAll()).rejects.toThrow('failed to close 1 PTY session')
|
||||
await expect(internal.disposeAll()).rejects.toThrow('failed to clean up PTY lifecycle')
|
||||
expect(internal.backends.size).toBe(0)
|
||||
expect(internal.ownerCleanups.size).toBe(0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user