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;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
2026-07-16-persistent-pty-sessions.md: a73b8d0247243cc0d9cc93f27d84a8540b3b271d
|
2026-07-16-persistent-pty-sessions.md: db161a865f1c30fc3979e1a92dc6d427ecb58f25
|
||||||
2026-07-16-persistent-pty-sessions.zh.md: b0dc4010f69206b7aaeab4164199ffd935165955
|
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.
|
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
|
### Security and process boundary
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ idle 检测属于后端行为,不是第二条公共 seam。远程或容器后
|
|||||||
|
|
||||||
实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。
|
实现不提供插件加载期 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)
|
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)
|
## `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.
|
- 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.
|
- 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.
|
- 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.
|
- `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.
|
- 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.
|
- 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 {
|
interface PendingSpawn {
|
||||||
readonly controller: AbortController
|
readonly controller: AbortController
|
||||||
readonly settled: Promise<void>
|
readonly settled: Promise<void>
|
||||||
|
rollbackFailure: { error: unknown } | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpawnReservation {
|
interface SpawnReservation {
|
||||||
readonly signal: AbortSignal
|
readonly signal: AbortSignal
|
||||||
release(): void
|
release(rollbackFailure: { error: unknown } | undefined): void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** In-process registry for replaceable PTY backends and exact-Agent sessions. */
|
/** 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])
|
: AbortSignal.any([signal, spawnReservation.signal])
|
||||||
const sessionId = PtySessionId(`pty-${++this.nextId}`)
|
const sessionId = PtySessionId(`pty-${++this.nextId}`)
|
||||||
let session: PtyBackendSession | undefined
|
let session: PtyBackendSession | undefined
|
||||||
|
let rollbackFailure: { error: unknown } | undefined
|
||||||
try {
|
try {
|
||||||
session = await backend.spawn({
|
session = await backend.spawn({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -189,7 +191,6 @@ export class PtyService extends Service {
|
|||||||
this.sessions.set(sessionId, record)
|
this.sessions.set(sessionId, record)
|
||||||
return this.snapshot(record, session.motd)
|
return this.snapshot(record, session.motd)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let rollbackFailure: { error: unknown } | undefined
|
|
||||||
if (session !== undefined && !this.sessions.has(sessionId)) {
|
if (session !== undefined && !this.sessions.has(sessionId)) {
|
||||||
try {
|
try {
|
||||||
await session.close('PTY spawn rolled back')
|
await session.close('PTY spawn rolled back')
|
||||||
@@ -209,7 +210,7 @@ export class PtyService extends Service {
|
|||||||
}
|
}
|
||||||
throw failure
|
throw failure
|
||||||
} finally {
|
} finally {
|
||||||
spawnReservation.release()
|
spawnReservation.release(rollbackFailure)
|
||||||
releaseName()
|
releaseName()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,13 +342,14 @@ export class PtyService extends Service {
|
|||||||
private reserveSpawn(owner: Agent): SpawnReservation {
|
private reserveSpawn(owner: Agent): SpawnReservation {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const settlement = Promise.withResolvers<void>()
|
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>()
|
const owned = this.pendingSpawns.get(owner) ?? new Set<PendingSpawn>()
|
||||||
owned.add(pending)
|
owned.add(pending)
|
||||||
this.pendingSpawns.set(owner, owned)
|
this.pendingSpawns.set(owner, owned)
|
||||||
return {
|
return {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
release: () => {
|
release: (rollbackFailure) => {
|
||||||
|
pending.rollbackFailure = rollbackFailure
|
||||||
owned.delete(pending)
|
owned.delete(pending)
|
||||||
if (owned.size === 0) this.pendingSpawns.delete(owner)
|
if (owned.size === 0) this.pendingSpawns.delete(owner)
|
||||||
settlement.resolve()
|
settlement.resolve()
|
||||||
@@ -361,6 +363,10 @@ export class PtyService extends Service {
|
|||||||
: [...(this.pendingSpawns.get(owner) ?? [])]
|
: [...(this.pendingSpawns.get(owner) ?? [])]
|
||||||
for (const spawn of pending) spawn.controller.abort(reason)
|
for (const spawn of pending) spawn.controller.abort(reason)
|
||||||
await Promise.all(pending.map(spawn => spawn.settled))
|
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 {
|
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> {
|
private async disposeOwned(owner: Agent): Promise<void> {
|
||||||
await this.abortPendingSpawns(owner, new PtyError('PTY owner is no longer live', 'OWNER_NOT_LIVE'))
|
try {
|
||||||
const owned = [...this.sessions.values()].filter(record => record.owner === owner)
|
await this.abortAndClose(
|
||||||
await this.closeRecords(owned, 'PTY owner disposed')
|
owner,
|
||||||
this.reservedNames.delete(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> {
|
private async disposeAll(): Promise<void> {
|
||||||
@@ -396,9 +423,11 @@ export class PtyService extends Service {
|
|||||||
// owner cleanups before the aggregated error propagates, so one stuck
|
// owner cleanups before the aggregated error propagates, so one stuck
|
||||||
// session cannot orphan backends, reservations, or owner detachers.
|
// session cannot orphan backends, reservations, or owner detachers.
|
||||||
try {
|
try {
|
||||||
await this.abortPendingSpawns(undefined, new PtyError('PTY service is disposing', 'SERVICE_DISPOSING'))
|
await this.abortAndClose(
|
||||||
const records = [...this.sessions.values()]
|
undefined,
|
||||||
await this.closeRecords(records, 'PTY service disposed')
|
new PtyError('PTY service is disposing', 'SERVICE_DISPOSING'),
|
||||||
|
'PTY service disposed',
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
this.backends.clear()
|
this.backends.clear()
|
||||||
this.reservedNames.clear()
|
this.reservedNames.clear()
|
||||||
|
|||||||
@@ -299,6 +299,26 @@ describe('PtyService ownership and lifecycle', () => {
|
|||||||
expect(session.closed).toEqual(['PTY spawn rolled back'])
|
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 () => {
|
it('keeps independent reservations and handles provider failure before publication', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
const firstGate = Promise.withResolvers<PtyBackendSession>()
|
const firstGate = Promise.withResolvers<PtyBackendSession>()
|
||||||
@@ -343,11 +363,16 @@ describe('PtyService ownership and lifecycle', () => {
|
|||||||
const failedSpawn = new StubSession()
|
const failedSpawn = new StubSession()
|
||||||
failedSpawn.rejectClose = true
|
failedSpawn.rejectClose = true
|
||||||
let ownerDisposal = Promise.resolve()
|
let ownerDisposal = Promise.resolve()
|
||||||
|
const internal = ctx.pty as unknown as {
|
||||||
|
disposedOwners: WeakSet<Agent>
|
||||||
|
disposeOwned(owner: Agent): Promise<void>
|
||||||
|
}
|
||||||
ctx.pty.registerBackend({
|
ctx.pty.registerBackend({
|
||||||
type: 'bad-spawn',
|
type: 'bad-spawn',
|
||||||
async spawn({ signal }) {
|
async spawn({ signal }) {
|
||||||
if (signal === undefined) throw new Error('missing 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) {
|
if (!signal.aborted) {
|
||||||
await new Promise<undefined>((resolve) => {
|
await new Promise<undefined>((resolve) => {
|
||||||
signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
|
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 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')
|
const nextOwner = stubAgent(ctx, 'next')
|
||||||
ctx.agents.register(nextOwner)
|
ctx.agents.register(nextOwner)
|
||||||
@@ -456,7 +481,7 @@ describe('PtyService ownership and lifecycle', () => {
|
|||||||
}
|
}
|
||||||
// Teardown surfaces the close failure, but its finally still clears the
|
// Teardown surfaces the close failure, but its finally still clears the
|
||||||
// backend and owner-cleanup registries instead of orphaning them.
|
// 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.backends.size).toBe(0)
|
||||||
expect(internal.ownerCleanups.size).toBe(0)
|
expect(internal.ownerCleanups.size).toBe(0)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user