diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 96d1e06bbe..de619790dc 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index a73b8d0247..db161a865f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index b0dc4010f6..06c902437a 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -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` 失败,因此输出与取消无法跨越操作所有权。 ### 安全与进程边界 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ab739db024..1f2cf86408 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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) diff --git a/packages/pty/pty/README.md b/packages/pty/pty/README.md index 624b771164..2dff1cbca6 100644 --- a/packages/pty/pty/README.md +++ b/packages/pty/pty/README.md @@ -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. diff --git a/packages/pty/pty/src/index.ts b/packages/pty/pty/src/index.ts index 1b937b9dff..d5b4d84e1f 100644 --- a/packages/pty/pty/src/index.ts +++ b/packages/pty/pty/src/index.ts @@ -90,11 +90,12 @@ interface SessionRecord { interface PendingSpawn { readonly controller: AbortController readonly settled: Promise + 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() - const pending: PendingSpawn = { controller, settled: settlement.promise } + const pending: PendingSpawn = { controller, settled: settlement.promise, rollbackFailure: undefined } const owned = this.pendingSpawns.get(owner) ?? new Set() 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 { + 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 { - 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 { @@ -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() diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 52d22bd4b3..49162abe5c 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -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() + 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 } + 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() @@ -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 + disposeOwned(owner: Agent): Promise + } 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((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) })