From 974b6c41fa8551da3ad7eccb004e85b08b4ef053 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 20 Aug 2026 06:44:24 -0400 Subject: [PATCH] fix(terminal): fence remaining exact-close cleanup --- config/reliability-gates.jsonc | 31 +++-- ...aemon-pty-adapter-session-adoption.test.ts | 2 + src/main/daemon/daemon-pty-adapter.ts | 4 + ...on-pty-router-incarnation-shutdown.test.ts | 26 ++++ src/main/daemon/daemon-pty-router.ts | 10 +- .../daemon/degraded-daemon-pty-provider.ts | 6 +- .../pty-daemon-controller-teardown.test.ts | 14 ++- src/main/ipc/pty-ssh-stop-verdict.test.ts | 16 +-- src/main/ipc/pty.ts | 52 ++++++-- src/main/providers/local-pty-provider.ts | 4 + src/main/providers/pty-provider-contract.ts | 5 + .../ssh-incarnation-shutdown-capability.ts | 36 ++++++ src/main/providers/ssh-pty-provider.test.ts | 28 +++++ src/main/providers/ssh-pty-provider.ts | 6 + .../runtime/exit-provenance-audit.test.ts | 4 +- ...-runtime-terminal-close-continuity.test.ts | 89 ++++++++++++-- ...a-runtime-terminal-split-authority.test.ts | 99 ++++++++++++---- src/main/runtime/orca-runtime.test.ts | 67 +++++++++-- src/main/runtime/orca-runtime.ts | 112 +++++++++++++----- src/relay/pty-handler.ts | 4 +- .../local-build-compatibility-contract.json | 4 +- .../local-build-compatibility-contract.ts | 4 +- src/shared/pty-incarnation.ts | 2 + 23 files changed, 514 insertions(+), 111 deletions(-) create mode 100644 src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts create mode 100644 src/main/providers/ssh-incarnation-shutdown-capability.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 81be10b3c06..191b736ea18 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -3380,7 +3380,7 @@ "providers": ["local", "daemon", "ssh", "runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh", "runtime"], - "coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A headed paired desktop/client journey proves a replacement handle remains writable and uniquely owned before terminal.closeTab, while an isolated CDP-attached desktop proves terminal.close acknowledges exact PTY teardown, removes the rendered tab, and leaves fresh host inventory empty. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, runtime-owned headless closeTab teardown, exact local/daemon/SSH/relay process-owner shutdown, old daemon and relay fail-closed behavior, same-id local replacement admission after physical exit, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression. The isolated headless orca serve fixture did not publish runtime/worktree state before terminal creation on 2026-08-20, so live headless close parity remains pending alongside Linux, Windows, WSL, and SSH process evidence.", + "coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A headed paired desktop/client journey proves a replacement handle remains writable and uniquely owned before terminal.closeTab, while an isolated CDP-attached desktop proves terminal.close acknowledges exact PTY teardown, removes the rendered tab, and leaves fresh host inventory empty. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, runtime-owned headless closeTab teardown, rejected-split exact cleanup, failed split-leaf stop retention, exact local/daemon/SSH/relay process-owner shutdown, pre-commit refusal for missing incarnation proof and old daemon/relay owners, same-id local replacement admission after physical exit, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression. The isolated headless orca serve fixture did not publish runtime/worktree state before terminal creation on 2026-08-20, so live headless close parity remains pending alongside Linux, Windows, WSL, and SSH process evidence.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/8628", "https://github.com/stablyai/orca/pull/8706", @@ -3389,11 +3389,11 @@ "https://github.com/stablyai/orca/pull/14590" ], "invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; a close authorized for terminal incarnation A never closes, kills, retires, or tombstones replacement incarnation B, while unchanged A still closes. Final-pane CLI close does not acknowledge before durable tab retirement, and a persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.", - "oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Pause final-pane terminal.close and terminal.closeTab first at the acknowledged renderer commit and then inside provider stop after that commit; replace or republish the PTY under the same tab/leaf before releasing each barrier. Replacement B must remain in the persisted session and live inventory, retain its exact process identity and readable handle, and never appear in renderer retirement, provider stop, cleanup, or stop-intent attribution; unchanged A must retire and stop exactly once. Cover runtime-published, runtime-owned headless, and renderer-graph tabs plus git and folder workspaces, including a reused PTY id with a new incarnation. A headless terminal.closeTab whose caller owns exact teardown must commit the session surface without launching an ID-only kill, then let the incarnation-addressed stop reject same-id B. Final-pane terminal.close must kill the exact PTY once, invoke one durability-acknowledged tab retirement, stay pending until the persisted row is removed, and never fall back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Exact shutdown must reach local, daemon, and SSH/relay process owners, fail closed against old owners, fence final cleanup, and preserve duplicate same-incarnation teardown coalescing. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.", + "oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Pause final-pane terminal.close and terminal.closeTab first at the acknowledged renderer commit and then inside provider stop after that commit; replace or republish the PTY under the same tab/leaf before releasing each barrier. Replacement B must remain in the persisted session and live inventory, retain its exact process identity and readable handle, and never appear in renderer retirement, provider stop, cleanup, or stop-intent attribution; unchanged A must retire and stop exactly once. Cover runtime-published, runtime-owned headless, and renderer-graph tabs plus git and folder workspaces, including a reused PTY id with a new incarnation. A headless terminal.closeTab whose caller owns exact teardown must commit the session surface without launching an ID-only kill, then let the incarnation-addressed stop reject same-id B. Rejected split cleanup must address the spawned incarnation and preserve a same-id replacement; an unconfirmed exact split-leaf stop must retain the whole tab without invoking renderer teardown. Final-pane terminal.close must kill the exact PTY once, invoke one durability-acknowledged tab retirement, stay pending until the persisted row is removed, and never fall back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Exact shutdown must reach local, daemon, and SSH/relay process owners, refuse missing incarnation proof or old-owner capability before tab retirement, fence final cleanup, and preserve duplicate same-incarnation teardown coalescing. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts src/main/runtime/rpc/terminal-tab-close-expectation.test.ts src/main/window/terminal-tab-close-request-relay.test.ts src/renderer/src/components/terminal/terminal-tab-close-expectation.test.ts src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts src/main/ipc/pty-daemon-controller-teardown.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/daemon-protocol-version.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/main/daemon/terminal-host.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts src/main/runtime/orca-runtime-terminal-split-authority.test.ts src/main/ipc/pty-daemon-controller-teardown.test.ts src/main/ipc/pty-ssh-stop-verdict.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts src/main/daemon/daemon-protocol-version.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/main/daemon/terminal-host.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-daemon-controller-teardown.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/daemon-protocol-version.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/terminal-host.test.ts src/relay/pty-handler-shutdown-signals.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal/initial-terminal.test.ts src/renderer/src/lib/worktree-activation-default-tabs.test.ts src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts", @@ -3406,10 +3406,13 @@ ], "testFiles": [ "src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts", + "src/main/runtime/orca-runtime-terminal-split-authority.test.ts", "src/main/ipc/pty-daemon-controller-teardown.test.ts", + "src/main/ipc/pty-ssh-stop-verdict.test.ts", "src/main/providers/local-pty-provider-spawn-session.test.ts", "src/main/providers/ssh-pty-provider.test.ts", "src/main/daemon/daemon-pty-adapter-session-adoption.test.ts", + "src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts", "src/main/daemon/daemon-protocol-version.test.ts", "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", "src/relay/pty-handler-shutdown-signals.test.ts", @@ -3455,6 +3458,13 @@ "same-PTY-id replacement is distinguished by provider incarnation while new-PTY replacement is distinguished by the exact binding" ] }, + { + "file": "src/main/runtime/orca-runtime-terminal-split-authority.test.ts", + "assertions": [ + "rejected split cleanup passes the spawned incarnation through exact stop and final retirement", + "a same-id replacement installed behind the stop barrier remains current and never reaches an ID-only kill" + ] + }, { "file": "src/main/ipc/pty-daemon-controller-teardown.test.ts", "assertions": [ @@ -3483,7 +3493,13 @@ { "file": "src/main/providers/ssh-pty-provider.test.ts", "assertions": [ - "SSH exact close uses pty.shutdownIncarnation and never falls back to unsafe id-only shutdown when an old relay rejects the method" + "SSH exact close proves the relay capability before commit, uses pty.shutdownIncarnation, and never falls back to unsafe id-only shutdown" + ] + }, + { + "file": "src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts", + "assertions": [ + "exact-stop capability follows the daemon adapter that owns the session instead of the current daemon" ] }, { @@ -3494,6 +3510,7 @@ "file": "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts", "assertions": [ "final-pane terminal.close remains pending until one renderer durability acknowledgement retires the persisted tab and layout", + "missing incarnation proof and old-owner exact-stop capability retain the tab before renderer acknowledgement", "post-ack workspace-session loss falls back to renderer cleanup and verified PTY teardown while every pre-ack retirement error remains non-destructive", "renderer reload with the same provider-attested PTY incarnation preserves the exact handle", "same-id replacement incarnation publishes a new handle and makes the old handle deterministically stale", @@ -3675,10 +3692,10 @@ "date": "2026-08-20", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts src/main/ipc/pty-daemon-controller-teardown.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/daemon-protocol-version.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/main/daemon/terminal-host.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-incarnation-race.test.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts src/main/runtime/orca-runtime-terminal-split-authority.test.ts src/main/ipc/pty-daemon-controller-teardown.test.ts src/main/ipc/pty-ssh-stop-verdict.test.ts src/main/providers/local-pty-provider-spawn-session.test.ts src/main/providers/local-pty-provider-shutdown.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/daemon/daemon-pty-adapter-session-adoption.test.ts src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts src/main/daemon/daemon-protocol-version.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/relay/pty-handler-shutdown-signals.test.ts src/main/daemon/terminal-host.test.ts", "result": "passed", - "durationSeconds": 4.25, - "summary": "Eleven files and 204 tests passed on candidate d02f2f22da, including a controlled headless ID-only-kill barrier plus the runtime close transaction, local same-id admission, controller cleanup fencing, daemon protocol skew, host duplicate teardown, SSH method skew, and relay process-owner rejection." + "durationSeconds": 4.87, + "summary": "Fourteen files and 230 tests passed on the final candidate, including controlled headless and rejected-split same-ID barriers, failed split-leaf retention, the runtime close transaction, local same-id admission, controller cleanup fencing, daemon protocol skew and owner routing, host duplicate teardown, SSH method skew, and relay process-owner rejection." }, { "date": "2026-08-20", diff --git a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts index 636f062b770..71c382ad609 100644 --- a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts +++ b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts @@ -653,6 +653,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it('fails closed before contacting a legacy daemon', async () => { const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 35 }) try { + expect(legacy.supportsIncarnationAddressedShutdown()).toBe(false) + expect(adapter.supportsIncarnationAddressedShutdown()).toBe(true) await expect( legacy.shutdown('legacy-session', { immediate: true, diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index bdbce17a02f..f4e05641e7e 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -422,6 +422,10 @@ export class DaemonPtyAdapter implements IPtyProvider { return this.protocolVersion >= AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION } + supportsIncarnationAddressedShutdown(): boolean { + return this.protocolVersion >= INCARNATION_ADDRESSED_SHUTDOWN_DAEMON_PROTOCOL_VERSION + } + providesAgentSessionOwnerListings(_ptyId: string): boolean { return this.supportsAgentSessionClaims() } diff --git a/src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts b/src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts new file mode 100644 index 00000000000..f3f1c49320b --- /dev/null +++ b/src/main/daemon/daemon-pty-router-incarnation-shutdown.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonPtyRouter } from './daemon-pty-router' + +function createAdapter(sessionId: string, supported: boolean): DaemonPtyAdapter { + const unsubscribe = () => {} + return { + listProcesses: vi.fn(async () => [{ id: sessionId, cwd: '', title: sessionId }]), + supportsIncarnationAddressedShutdown: () => supported, + onData: vi.fn(() => unsubscribe), + onExit: vi.fn(() => unsubscribe), + onDaemonIdentityChanged: vi.fn(() => unsubscribe) + } as unknown as DaemonPtyAdapter +} + +describe('daemon PTY router incarnation shutdown capability', () => { + it('uses the adapter that owns the exact session', async () => { + const current = createAdapter('current-session', true) + const legacy = createAdapter('legacy-session', false) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + expect(router.supportsIncarnationAddressedShutdown('current-session')).toBe(true) + expect(router.supportsIncarnationAddressedShutdown('legacy-session')).toBe(false) + }) +}) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index fdc21f479f2..d3203e72831 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -68,10 +68,12 @@ export class DaemonPtyRouter implements IPtyProvider { return adapter?.providesAgentSessionOwnerListings(ptyId) === true } - supportsAgentSessionCreateOperations(): boolean { - // Fresh sessions always route to the current daemon; legacy adapters only retain old IDs. - return this.current.supportsAgentSessionCreateOperations() - } + // Fresh sessions always route to the current daemon; legacy adapters only retain old IDs. + supportsAgentSessionCreateOperations = (): boolean => + this.current.supportsAgentSessionCreateOperations() + + supportsIncarnationAddressedShutdown = (id: string): boolean => + this.adapterFor(id).supportsIncarnationAddressedShutdown() async attach(id: string): ReturnType { return await this.adapterFor(id).attach(id) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 7479dd4b308..e58906b6d5b 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -77,6 +77,8 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { recoverFreshSpawnRouting = (): Promise => this.freshSpawns.recover() + supportsIncarnationAddressedShutdown = (id: string, opts?: { deadlineMs?: number }) => + this.providerFor(id).supportsIncarnationAddressedShutdown?.(id, opts) ?? false supportsGitCredentialGuardHost = (id?: string): boolean => this.freshSpawns.supportsGitGuardHost(id) @@ -108,9 +110,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { this.sessionProviders.get(ptyId) ?? this.findProviderForExistingSession(ptyId) )?.providesAgentSessionOwnerListings?.(ptyId) === true - write(id: string, data: string): boolean | void { - return this.providerFor(id).write(id, data) - } + write = (id: string, data: string): boolean | void => this.providerFor(id).write(id, data) async writeWithSettlement(id: string, data: string): Promise { const provider = this.providerFor(id) diff --git a/src/main/ipc/pty-daemon-controller-teardown.test.ts b/src/main/ipc/pty-daemon-controller-teardown.test.ts index 8fbc6210cd1..65c520304f5 100644 --- a/src/main/ipc/pty-daemon-controller-teardown.test.ts +++ b/src/main/ipc/pty-daemon-controller-teardown.test.ts @@ -265,12 +265,18 @@ describe('registerPtyHandlers', () => { onPtyExit: vi.fn(), registerPty: vi.fn(), onPtySpawned: vi.fn(), - markPtyStopRequested: vi.fn() + markPtyStopRequested: vi.fn(), + markPtyLivenessUnverifiable: vi.fn() } handlers.clear() registerPtyHandlers(mainWindow as never, runtime as never) const controller = runtime.setPtyController.mock.calls[0]?.[0] as { spawn: (args: { cols: number; rows: number }) => Promise<{ id: string }> + retireRejectedPty: ( + ptyId: string, + stopConfirmed: boolean, + expectedIncarnationId: string + ) => void stopAndWait: (ptyId: string, opts: { expectedIncarnationId: string }) => Promise } @@ -283,7 +289,9 @@ describe('registerPtyHandlers', () => { releaseShutdown() await expect(stopping).resolves.toBe(false) + controller.retireRejectedPty('reused-pty', false, 'incarnation-a') expect(liveIncarnation).toBe('incarnation-b') + expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalledWith( 'reused-pty', expect.anything(), @@ -443,7 +451,7 @@ describe('registerPtyHandlers', () => { ) expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) }) - it('keeps a rejected SSH PTY unverifiable after kill shutdown fails transiently', async () => { + it('does not overwrite a failed kill verdict with unproven rejected retirement', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const store = { markSshRemotePtyLease: vi.fn() @@ -511,7 +519,7 @@ describe('registerPtyHandlers', () => { ) expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith( 'remote-pty', - 'a follow-up stop was issued but its outcome could not be verified' + 'Multiplexer disposed' ) expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined) expect(runtime.onPtyExit).not.toHaveBeenCalledWith('remote-pty', 0, undefined) diff --git a/src/main/ipc/pty-ssh-stop-verdict.test.ts b/src/main/ipc/pty-ssh-stop-verdict.test.ts index 8001028ea1d..6100f82c7e4 100644 --- a/src/main/ipc/pty-ssh-stop-verdict.test.ts +++ b/src/main/ipc/pty-ssh-stop-verdict.test.ts @@ -63,7 +63,11 @@ describe('stopping a PTY whose SSH provider is unregistered', () => { controller: { kill: (ptyId: string) => boolean listProcesses: (connectionId?: string | null) => Promise<{ id: string }[]> - retireRejectedPty: (ptyId: string, stopConfirmed: boolean) => void + retireRejectedPty: ( + ptyId: string, + stopConfirmed: boolean, + expectedIncarnationId?: string + ) => void stopAndWait: (ptyId: string, opts?: { deadlineMs?: number }) => Promise } runtime: { @@ -131,19 +135,15 @@ describe('stopping a PTY whose SSH provider is unregistered', () => { } }) - it('retires a rejected split without asserting an unconfirmed exit', () => { + it('refuses rejected split retirement without incarnation proof', () => { const ptyId = 'ssh-rejected-split' setPtyOwnership(ptyId, 'ssh-dropped') const { controller, runtime } = installController() try { controller.retireRejectedPty(ptyId, false) - expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith( - ptyId, - 'a follow-up stop was issued but its outcome could not be verified' - ) - expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined) - expect(runtime.onPtyExit).not.toHaveBeenCalledWith(ptyId, 0, expect.anything()) + expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalled() + expect(runtime.onPtyExit).not.toHaveBeenCalled() } finally { deletePtyOwnership(ptyId) } diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e91d7da00d0..a021aa3bf2d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5703,21 +5703,18 @@ export function registerPtyHandlers( } return killWithCurrentProvider() }, - retireRejectedPty: (ptyId, stopConfirmed) => { - rememberRetiredRejectedPty(ptyId) + retireRejectedPty: (ptyId, stopConfirmed, expectedIncarnationId) => { + if (!expectedIncarnationId || ptyIncarnationById.get(ptyId) !== expectedIncarnationId) { + return + } if (!stopConfirmed) { runtime?.markPtyLivenessUnverifiable?.( ptyId, 'a follow-up stop was issued but its outcome could not be verified' ) - if (!ptyOwnership.has(ptyId)) { - return - } - runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId)) - rememberSyntheticKillExit(ptyId) - sendPtyExitToRenderer({ id: ptyId, code: -1 }) return } + rememberRetiredRejectedPty(ptyId) // Why: a completed stop already cleared provider state, tombstoned the lease and told the // renderer; repeating that double-fires the exit IPC. if (!ptyOwnership.has(ptyId)) { @@ -5727,7 +5724,11 @@ export function registerPtyHandlers( let connectionId: string | null | undefined = ptyOwnership.get(ptyId) const parsedSshId = connectionId === undefined ? parseAppSshPtyId(ptyId) : null connectionId ??= parsedSshId?.connectionId - const incarnationId = finishPtyShutdown(ptyId, connectionId, store) + const finished = finishExpectedPtyShutdown(ptyId, connectionId, store, expectedIncarnationId) + if (!finished.finished) { + return + } + const { incarnationId } = finished runtime?.onPtyExit(ptyId, 0, incarnationId) rememberSyntheticKillExit(ptyId) sendPtyExitToRenderer({ id: ptyId, code: 0 }) @@ -5874,6 +5875,39 @@ export function registerPtyHandlers( } return true }, + supportsIncarnationAddressedStop: async (ptyId, opts) => { + const connectionId = ptyOwnership.get(ptyId) ?? parseAppSshPtyId(ptyId)?.connectionId ?? null + const startupPromise = getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise) { + const deadlineMs = opts?.deadlineMs + if (deadlineMs !== undefined) { + const ready = await Promise.race([ + startupPromise.then( + () => true, + () => false + ), + delay(Math.max(1, deadlineMs - Date.now())).then(() => false) + ]) + if (!ready) { + return false + } + } else { + try { + await startupPromise + } catch { + return false + } + } + } + const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(ptyId) + const supported = + (await provider?.supportsIncarnationAddressedShutdown?.(ptyId, opts)) === true + return ( + supported && + opts?.expectedIncarnationId !== undefined && + ptyIncarnationById.get(ptyId) === opts.expectedIncarnationId + ) + }, getForegroundProcess: async (ptyId) => { try { return await getProviderForPty(ptyId).getForegroundProcess(ptyId) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 91dae9d77fa..f0637cd54ca 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -542,6 +542,10 @@ export type LocalPtyProviderOptions = { } export class LocalPtyProvider implements IPtyProvider { + supportsIncarnationAddressedShutdown(): boolean { + return true + } + private opts: LocalPtyProviderOptions constructor(opts: LocalPtyProviderOptions = {}) { diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 37cccedf619..632b6b57348 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -191,6 +191,11 @@ export type IPtyProvider = { */ getAppliedSize?: (id: string) => Promise<{ cols: number; rows: number } | null> + supportsIncarnationAddressedShutdown?: ( + id: string, + opts?: { deadlineMs?: number } + ) => boolean | Promise + // Why: deadlineMs (absolute epoch ms) bounds the underlying RPCs so destructive // teardown fails fast inside its sweep budget instead of tripping the outer sweep // deadline; each RPC leaf converts to a relative timeout when it actually issues. diff --git a/src/main/providers/ssh-incarnation-shutdown-capability.ts b/src/main/providers/ssh-incarnation-shutdown-capability.ts new file mode 100644 index 00000000000..9035f152b26 --- /dev/null +++ b/src/main/providers/ssh-incarnation-shutdown-capability.ts @@ -0,0 +1,36 @@ +import { PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION } from '../../shared/pty-incarnation' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' + +export class SshIncarnationShutdownCapability { + private probe: Promise | null = null + + constructor(private readonly mux: SshChannelMultiplexer) {} + + async supports(opts: { deadlineMs?: number } = {}): Promise { + const probe = + this.probe ?? + this.mux + .request( + 'pty.getCapabilities', + undefined, + opts.deadlineMs === undefined + ? undefined + : { timeoutMs: Math.max(1, opts.deadlineMs - Date.now()) } + ) + .then( + (result) => + (result as { incarnationAddressedShutdownVersion?: unknown }) + .incarnationAddressedShutdownVersion === PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION + ) + // Why: relay upgrades replace the connection/provider, so a negative result is stable here. + this.probe = probe + try { + return await probe + } catch { + if (this.probe === probe) { + this.probe = null + } + return false + } + } +} diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 41fa432d7b3..8415cc5c8ae 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshPtyProvider } from './ssh-pty-provider' import { AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION } from '../../shared/agent-session-host-authority' +import { PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION } from '../../shared/pty-incarnation' import { createMockMux, expectRequest, @@ -181,6 +182,33 @@ describe('SshPtyProvider', () => { ) }) + it('proves incarnation-addressed shutdown before a close commit', async () => { + mux.request.mockResolvedValue({ + incarnationAddressedShutdownVersion: PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION + }) + + await expect(provider.supportsIncarnationAddressedShutdown(scopedPty1)).resolves.toBe(true) + expectRequest(mux.request, 'pty.getCapabilities', undefined, undefined) + }) + + it('fails closed when an old relay omits incarnation-addressed shutdown', async () => { + mux.request.mockResolvedValue({}) + + await expect(provider.supportsIncarnationAddressedShutdown(scopedPty1)).resolves.toBe(false) + await expect(provider.supportsIncarnationAddressedShutdown(scopedPty1)).resolves.toBe(false) + expect(mux.request).toHaveBeenCalledOnce() + }) + + it('retries incarnation capability after a transient relay failure', async () => { + mux.request.mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ + incarnationAddressedShutdownVersion: PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION + }) + + await expect(provider.supportsIncarnationAddressedShutdown(scopedPty1)).resolves.toBe(false) + await expect(provider.supportsIncarnationAddressedShutdown(scopedPty1)).resolves.toBe(true) + expect(mux.request).toHaveBeenCalledTimes(2) + }) + it('does not fall back to an unsafe shutdown on an old relay', async () => { mux.request.mockRejectedValueOnce(new Error('Method not found: pty.shutdownIncarnation')) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index d6f8938456c..48b119349d5 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -23,6 +23,7 @@ import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' import type { PtyProcessInspection } from './pty-process-inspection' import { writeToSshPty, writeToSshPtyWithSettlement } from './ssh-pty-write' +import { SshIncarnationShutdownCapability } from './ssh-incarnation-shutdown-capability' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { @@ -36,6 +37,7 @@ export class SshPtyProvider implements IPtyProvider { private livePtyIds = new Set() readonly getAppliedSize: NonNullable private readonly agentSessionCapabilities: SshAgentSessionCapabilities + private readonly incarnationShutdownCapability: SshIncarnationShutdownCapability private spawnExitRaces = new SshPtySpawnExitRaceTracker() private readonly outputState: SshPtyProviderOutputState @@ -51,6 +53,7 @@ export class SshPtyProvider implements IPtyProvider { this.connectionId = connectionId this.mux = mux this.agentSessionCapabilities = new SshAgentSessionCapabilities(mux) + this.incarnationShutdownCapability = new SshIncarnationShutdownCapability(mux) this.getAppliedSize = createSshPtyAppliedSizeReader(mux, connectionId) this.outputState = new SshPtyProviderOutputState(providerGeneration, { @@ -201,6 +204,9 @@ export class SshPtyProvider implements IPtyProvider { return writeToSshPtyWithSettlement(this.mux, this.toRelayPtyId(id), data) } + supportsIncarnationAddressedShutdown = (_id: string, opts: { deadlineMs?: number } = {}) => + this.incarnationShutdownCapability.supports(opts) + resize(id: string, cols: number, rows: number): void { this.mux.notify('pty.resize', { id: this.toRelayPtyId(id), cols, rows }) } diff --git a/src/main/runtime/exit-provenance-audit.test.ts b/src/main/runtime/exit-provenance-audit.test.ts index ee6b713dd61..ea49baafcc3 100644 --- a/src/main/runtime/exit-provenance-audit.test.ts +++ b/src/main/runtime/exit-provenance-audit.test.ts @@ -247,8 +247,8 @@ describe('STA-4603/STA-4536 exit provenance', () => { write: () => true, kill: () => true, getForegroundProcess: async () => null, - stopAndWait: async (ptyId: string) => { - runtime.onPtyExit(ptyId, 0) + stopAndWait: async (ptyId: string, opts) => { + runtime.onPtyExit(ptyId, 0, opts?.expectedIncarnationId) return true } }) diff --git a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts index a15fdd7218a..56dd0ac99da 100644 --- a/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-continuity.test.ts @@ -16,7 +16,10 @@ const SIBLING_PTY_ID = 'pty-close-continuity-sibling' const INCARNATION_ID = '22222222-2222-4222-8222-222222222222' const SIBLING_INCARNATION_ID = '44444444-4444-4444-8444-444444444444' -function makeSession(ptyId = PTY_ID): WorkspaceSessionState { +function makeSession( + ptyId = PTY_ID, + incarnationId: string | null = INCARNATION_ID +): WorkspaceSessionState { return { ...getDefaultWorkspaceSession(), tabsByWorktree: { @@ -41,9 +44,9 @@ function makeSession(ptyId = PTY_ID): WorkspaceSessionState { ptyIdsByLeafId: { [LEAF_ID]: ptyId } } }, - terminalPtyIncarnationsByPaneKey: { - [makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID - } + terminalPtyIncarnationsByPaneKey: incarnationId + ? { [makePaneKey(TAB_ID, LEAF_ID)]: incarnationId } + : {} } } @@ -56,12 +59,19 @@ function makeDeferred() { } function createHarness( - options: { ptyId?: string; publishMobileSurface?: boolean; registerPtyBacked?: boolean } = {} + options: { + ptyId?: string + incarnationId?: string | null + publishMobileSurface?: boolean + registerPtyBacked?: boolean + } = {} ) { const ptyId = options.ptyId ?? PTY_ID - let session = makeSession(ptyId) + const initialIncarnationId = + options.incarnationId === undefined ? INCARNATION_ID : options.incarnationId + let session = makeSession(ptyId, initialIncarnationId) let sessionAvailable = true - let incarnationId = INCARNATION_ID + let incarnationId = initialIncarnationId let includeSiblingPty = false const repo = { id: REPO_ID, @@ -96,10 +106,12 @@ function createHarness( } return verifiedStopResult }) + let exactStopSupported = true + const supportsIncarnationAddressedStop = vi.fn(async () => exactStopSupported) const listProcesses = vi.fn(async () => [ { id: ptyId, - incarnationId, + ...(incarnationId ? { incarnationId } : {}), cwd: WORKTREE_PATH, title: 'Fixture shell' }, @@ -120,6 +132,7 @@ function createHarness( write: () => true, kill, stopAndWait, + supportsIncarnationAddressedStop, listProcesses, getForegroundProcess: async () => null }) @@ -243,7 +256,7 @@ function createHarness( runtime.registerPty(ptyId, WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_ID, - incarnationId: INCARNATION_ID + ...(initialIncarnationId ? { incarnationId: initialIncarnationId } : {}) }) } syncFixtureGraph() @@ -254,6 +267,7 @@ function createHarness( closeTerminalTab, kill, stopAndWait, + supportsIncarnationAddressedStop, syncEmptyGraph, syncFixtureGraph, syncFixtureTabWithoutLeaf, @@ -276,6 +290,9 @@ function createHarness( setVerifiedStopResult: (result: boolean | Error) => { verifiedStopResult = result }, + setExactStopSupported: (supported: boolean) => { + exactStopSupported = supported + }, replaceIncarnation: (next: string) => { incarnationId = next } @@ -329,6 +346,33 @@ describe('terminal close and handle incarnation continuity', () => { expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) }) + it('retains the tab when the addressed PTY has no incarnation proof', async () => { + const harness = createHarness({ incarnationId: null }) + const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals + + await expect(harness.runtime.closeTerminal(handle)).rejects.toThrow( + 'terminal_incarnation_fence_unavailable' + ) + + expect(harness.closeTerminalTab).not.toHaveBeenCalled() + expect(harness.stopAndWait).not.toHaveBeenCalled() + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + }) + + it('retains the tab when its process owner cannot stop by incarnation', async () => { + const harness = createHarness() + harness.setExactStopSupported(false) + const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals + + await expect(harness.runtime.closeTerminal(handle)).rejects.toThrow( + 'terminal_incarnation_fence_unavailable' + ) + + expect(harness.closeTerminalTab).not.toHaveBeenCalled() + expect(harness.stopAndWait).not.toHaveBeenCalled() + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + }) + it('requests a stop for every live tab PTY after retirement when the renderer graph is stale', async () => { const harness = createHarness() harness.setVerifiedStopResult(true) @@ -363,6 +407,10 @@ describe('terminal close and handle incarnation continuity', () => { const closing = harness.runtime.closeTerminal(handle) await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled()) + expect(harness.supportsIncarnationAddressedStop).toHaveBeenCalledWith(PTY_ID, { + deadlineMs: expect.any(Number), + expectedIncarnationId: INCARNATION_ID + }) expect(harness.stopAndWait).not.toHaveBeenCalled() harness.retirePersistedTab() @@ -415,6 +463,29 @@ describe('terminal close and handle incarnation continuity', () => { expect(harness.kill).not.toHaveBeenCalled() }) + it('retains a split tab when exact leaf stop is unconfirmed', async () => { + const harness = createHarness() + harness.syncSplitFixtureGraph() + harness.setVerifiedStopResult(false) + const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find( + (candidate) => candidate.ptyId === PTY_ID + )! + + await expect(harness.runtime.closeTerminal(terminal.handle)).resolves.toMatchObject({ + ptyKilled: false, + ptyStopVerdict: 'unverifiable' + }) + + expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, { + deadlineMs: expect.any(Number), + expectedIncarnationId: INCARNATION_ID + }) + expect(harness.closeTerminal).not.toHaveBeenCalled() + expect(harness.closeTerminalTab).not.toHaveBeenCalled() + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + }) + it('leaves a confirmed kill receipt free of any stop verdict', async () => { const harness = createHarness() const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index a5adbedd269..5b236b1c3a0 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -12,6 +12,8 @@ const TAB_ID = 'tab-remote' const SOURCE_LEAF_ID = '11111111-1111-4111-8111-111111111111' const SOURCE_PTY_ID = 'pty-source' const SPLIT_PTY_ID = 'pty-split' +const SPLIT_INCARNATION_ID = '22222222-2222-4222-8222-222222222222' +const REPLACEMENT_SPLIT_INCARNATION_ID = '33333333-3333-4333-8333-333333333333' function sourceLayout(): TerminalLayoutSnapshot { return { @@ -76,6 +78,7 @@ function createHarness( connectionId?: string | null deferReveal?: boolean deferSpawn?: boolean + deferStop?: boolean includePairedSnapshot?: boolean rendererMounted?: boolean sourceIncarnationId?: string @@ -106,18 +109,26 @@ function createHarness( }, persistPtyBinding: () => true } - let resolveSpawn: ((result: { id: string }) => void) | undefined + let resolveSpawn: ((result: { id: string; incarnationId: string }) => void) | undefined const spawn = options.deferSpawn ? vi.fn( () => - new Promise<{ id: string }>((resolve) => { + new Promise<{ id: string; incarnationId: string }>((resolve) => { resolveSpawn = resolve }) ) - : vi.fn(async () => ({ id: SPLIT_PTY_ID })) + : vi.fn(async () => ({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID })) const kill = vi.fn(() => true) const retireRejectedPty = vi.fn() - const stopAndWait = vi.fn(async () => options.stopAndWaitResult ?? true) + let resolveStop: ((stopped: boolean) => void) | undefined + const stopAndWait = options.deferStop + ? vi.fn( + () => + new Promise((resolve) => { + resolveStop = resolve + }) + ) + : vi.fn(async () => options.stopAndWaitResult ?? true) let resolveReveal: ((result: { tabId: string }) => void) | undefined const revealTerminalSession = options.deferReveal ? vi.fn( @@ -142,7 +153,7 @@ function createHarness( write: () => true, kill, retireRejectedPty, - ...(options.stopAndWaitResult !== undefined ? { stopAndWait } : {}), + ...(options.stopAndWaitResult !== undefined || options.deferStop ? { stopAndWait } : {}), getForegroundProcess: async () => null }) runtime.setNotifier({ revealTerminalSession } as never) @@ -181,7 +192,7 @@ function createHarness( const internals = runtime as unknown as { issuePtyHandle: (pty: unknown) => string mobileSessionTabsByWorktree: Map - ptysById: Map + ptysById: Map } const handle = internals.issuePtyHandle(internals.ptysById.get(SOURCE_PTY_ID)) return { @@ -211,7 +222,13 @@ function createHarness( } }, resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }), - resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID }) + replaceSplitIncarnation: () => + runtime.onPtySpawned(SPLIT_PTY_ID, REPLACEMENT_SPLIT_INCARNATION_ID, { + awaitsRegistration: false + }), + getSplitIncarnation: () => internals.ptysById.get(SPLIT_PTY_ID)?.incarnationId, + resolveStop: (stopped: boolean) => resolveStop?.(stopped), + resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID }) } } @@ -300,7 +317,7 @@ describe('remote runtime terminal split authority', () => { harness.resolveSpawn() await expect(split).rejects.toThrow('terminal_split_source_not_found') - expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) + expect(harness.kill).not.toHaveBeenCalled() expect(harness.requestedSessionHostIds).toContain(expectedHostId) } ) @@ -325,7 +342,7 @@ describe('remote runtime terminal split authority', () => { harness.resolveSpawn() await expect(split).rejects.toThrow('terminal_split_source_not_found') - expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) + expect(harness.kill).not.toHaveBeenCalled() expect(harness.revealTerminalSession).not.toHaveBeenCalled() }) @@ -345,10 +362,13 @@ describe('remote runtime terminal split authority', () => { await expect(split).rejects.toThrow('terminal_split_source_not_found') expect(harness.stopAndWait).toHaveBeenCalledWith( SPLIT_PTY_ID, - expect.objectContaining({ deadlineMs: expect.any(Number) }) + expect.objectContaining({ + deadlineMs: expect.any(Number), + expectedIncarnationId: SPLIT_INCARNATION_ID + }) ) expect(harness.kill).not.toHaveBeenCalled() - expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true) + expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true, SPLIT_INCARNATION_ID) }) it('revalidates a projected paired-runtime source after renderer adoption', async () => { @@ -371,22 +391,57 @@ describe('remote runtime terminal split authority', () => { await expect(split).rejects.toThrow('terminal_split_source_not_found') expect(harness.stopAndWait).toHaveBeenCalledWith( SPLIT_PTY_ID, - expect.objectContaining({ deadlineMs: expect.any(Number) }) + expect.objectContaining({ + deadlineMs: expect.any(Number), + expectedIncarnationId: SPLIT_INCARNATION_ID + }) + ) + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.retireRejectedPty).toHaveBeenCalledWith( + SPLIT_PTY_ID, + false, + SPLIT_INCARNATION_ID ) - expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) - expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false) }) - it('preserves the split error when kill and retirement throw', async () => { + it('addresses rejected split cleanup to the spawned incarnation after same-ID reuse', async () => { + const harness = createHarness(false, { + deferReveal: true, + deferStop: true, + includePairedSnapshot: true, + sourceIncarnationId: 'projected-before' + }) + + const split = harness.runtime.splitTerminal(harness.handle, { direction: 'horizontal' }) + await vi.waitFor(() => expect(harness.revealTerminalSession).toHaveBeenCalledOnce()) + harness.replaceSourceIncarnation('projected-after') + harness.resolveReveal() + await vi.waitFor(() => expect(harness.stopAndWait).toHaveBeenCalledOnce()) + + harness.replaceSplitIncarnation() + harness.resolveStop(false) + + await expect(split).rejects.toThrow('terminal_split_source_not_found') + expect(harness.stopAndWait).toHaveBeenCalledWith(SPLIT_PTY_ID, { + deadlineMs: expect.any(Number), + expectedIncarnationId: SPLIT_INCARNATION_ID + }) + expect(harness.retireRejectedPty).toHaveBeenCalledWith( + SPLIT_PTY_ID, + false, + SPLIT_INCARNATION_ID + ) + expect(harness.getSplitIncarnation()).toBe(REPLACEMENT_SPLIT_INCARNATION_ID) + expect(harness.kill).not.toHaveBeenCalled() + }) + + it('preserves the split error when exact retirement throws', async () => { const harness = createHarness(false, { deferReveal: true, includePairedSnapshot: true, sourceIncarnationId: 'projected-before', stopAndWaitResult: false }) - harness.kill.mockImplementation(() => { - throw new Error('kill failed') - }) harness.retireRejectedPty.mockImplementation(() => { throw new Error('retire failed') }) @@ -397,7 +452,11 @@ describe('remote runtime terminal split authority', () => { harness.resolveReveal() await expect(split).rejects.toThrow('terminal_split_source_not_found') - expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID) - expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false) + expect(harness.kill).not.toHaveBeenCalled() + expect(harness.retireRejectedPty).toHaveBeenCalledWith( + SPLIT_PTY_ID, + false, + SPLIT_INCARNATION_ID + ) }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 140c7fa71b5..9968b00fcfb 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -15476,19 +15476,23 @@ describe('OrcaRuntimeService', () => { } }) ) - let resolveSpawn!: (result: { id: string }) => void + let resolveSpawn!: (result: { id: string; incarnationId: string }) => void const spawn = vi.fn( (_args: unknown) => - new Promise<{ id: string }>((resolve) => { + new Promise<{ id: string; incarnationId: string }>((resolve) => { resolveSpawn = resolve }) ) const kill = vi.fn(() => false) + const stopAndWait = vi.fn(async () => true) + const retireRejectedPty = vi.fn() const runtime = new OrcaRuntimeService(runtimeStore as never) runtime.setPtyController({ spawn, write: () => true, kill, + stopAndWait, + retireRejectedPty, getForegroundProcess: async () => null }) runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) @@ -15506,7 +15510,7 @@ describe('OrcaRuntimeService', () => { setSession(getDefaultWorkspaceSession()) runtimeStore.persistPtyBinding.mockReturnValue(false) - resolveSpawn({ id: 'rejected-split-pty' }) + resolveSpawn({ id: 'rejected-split-pty', incarnationId: 'rejected-split-incarnation' }) await expect(split).rejects.toThrow('terminal_split_source_not_found') expect(spawn.mock.calls[0]?.[0]).toMatchObject({ @@ -15518,28 +15522,44 @@ describe('OrcaRuntimeService', () => { ptyId } }) - expect(kill).toHaveBeenCalledWith('rejected-split-pty') + expect(stopAndWait).toHaveBeenCalledWith('rejected-split-pty', { + deadlineMs: expect.any(Number), + expectedIncarnationId: 'rejected-split-incarnation' + }) + expect(retireRejectedPty).toHaveBeenCalledWith( + 'rejected-split-pty', + true, + 'rejected-split-incarnation' + ) + expect(kill).not.toHaveBeenCalled() expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toBeUndefined() expect(getSession().terminalLayoutsByTabId[tabId]).toBeUndefined() }) it('rejects a projected split retired during spawn before publishing the new pane', async () => { - let resolveSplitSpawn!: (result: { id: string }) => void + let resolveSplitSpawn!: (result: { id: string; incarnationId: string }) => void const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'projected-source-pty' }) + .mockResolvedValueOnce({ + id: 'projected-source-pty', + incarnationId: 'projected-source-incarnation' + }) .mockImplementationOnce( () => - new Promise<{ id: string }>((resolve) => { + new Promise<{ id: string; incarnationId: string }>((resolve) => { resolveSplitSpawn = resolve }) ) const kill = vi.fn(() => true) + const stopAndWait = vi.fn(async () => true) + const retireRejectedPty = vi.fn() const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ spawn, write: () => true, kill, + stopAndWait, + retireRejectedPty, getForegroundProcess: async () => null }) runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) @@ -15549,10 +15569,22 @@ describe('OrcaRuntimeService', () => { await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) runtime['mobileSessionTabsByWorktree'].delete(TEST_WORKTREE_ID) - resolveSplitSpawn({ id: 'retired-projected-split-pty' }) + resolveSplitSpawn({ + id: 'retired-projected-split-pty', + incarnationId: 'retired-projected-split-incarnation' + }) await expect(split).rejects.toThrow('terminal_split_source_not_found') - expect(kill).toHaveBeenCalledWith('retired-projected-split-pty') + expect(stopAndWait).toHaveBeenCalledWith('retired-projected-split-pty', { + deadlineMs: expect.any(Number), + expectedIncarnationId: 'retired-projected-split-incarnation' + }) + expect(retireRejectedPty).toHaveBeenCalledWith( + 'retired-projected-split-pty', + true, + 'retired-projected-split-incarnation' + ) + expect(kill).not.toHaveBeenCalled() expect(runtime['mobileSessionTabsByWorktree'].has(TEST_WORKTREE_ID)).toBe(false) }) @@ -29538,14 +29570,16 @@ describe('OrcaRuntimeService', () => { const flushOrThrow = vi.fn() const spawn = vi .fn() - .mockResolvedValueOnce({ id: 'headless-left' }) - .mockResolvedValueOnce({ id: 'headless-right' }) + .mockResolvedValueOnce({ id: 'headless-left', incarnationId: 'headless-left-incarnation' }) + .mockResolvedValueOnce({ id: 'headless-right', incarnationId: 'headless-right-incarnation' }) const kill = vi.fn(() => true) + const stopAndWait = vi.fn(async () => true) const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) runtime.setPtyController({ spawn, write: () => true, kill, + stopAndWait, getForegroundProcess: async () => null }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) @@ -29557,8 +29591,15 @@ describe('OrcaRuntimeService', () => { await runtime.closeTerminalTab(terminal.handle) - expect(kill).toHaveBeenCalledWith('headless-left') - expect(kill).toHaveBeenCalledWith('headless-right') + expect(stopAndWait).toHaveBeenCalledWith( + 'headless-left', + expect.objectContaining({ expectedIncarnationId: 'headless-left-incarnation' }) + ) + expect(stopAndWait).toHaveBeenCalledWith( + 'headless-right', + expect.objectContaining({ expectedIncarnationId: 'headless-right-incarnation' }) + ) + expect(kill).not.toHaveBeenCalled() expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) expect(getSession().terminalLayoutsByTabId['durable-tab']).toBeUndefined() expect(flushOrThrow).toHaveBeenCalledTimes(1) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6ab7f57b7a2..f14e4ebbb69 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1911,7 +1911,11 @@ type RuntimePtyController = { * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ attach?(ptyId: string): Promise kill(ptyId: string): boolean - retireRejectedPty?(ptyId: string, stopConfirmed: boolean): void + retireRejectedPty?( + ptyId: string, + stopConfirmed: boolean, + expectedIncarnationId?: PtyIncarnationId + ): void stopAndWait?( ptyId: string, opts?: { @@ -1920,6 +1924,10 @@ type RuntimePtyController = { expectedIncarnationId?: PtyIncarnationId } ): Promise + supportsIncarnationAddressedStop?( + ptyId: string, + opts?: { deadlineMs?: number; expectedIncarnationId?: PtyIncarnationId } + ): boolean | Promise markReversibleStops?(ptyIds: readonly string[]): () => void getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise @@ -29615,6 +29623,12 @@ export class OrcaRuntimeService { tabId: string ): { ptyId: string; incarnationId?: string }[] { const targets = new Map() + const persistedPtyIds = new Set( + Object.values( + this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId[tabId] + ?.ptyIdsByLeafId ?? {} + ) + ) const remember = (ptyId: string, incarnationId?: string): void => { if (!targets.has(ptyId) || incarnationId !== undefined) { targets.set(ptyId, incarnationId) @@ -29622,17 +29636,23 @@ export class OrcaRuntimeService { } for (const pty of this.ptysById.values()) { if (pty.connected && pty.worktreeId === worktreeId && pty.tabId === tabId) { - remember(pty.ptyId, pty.incarnationId ?? undefined) + if (pty.incarnationId || persistedPtyIds.has(pty.ptyId)) { + remember(pty.ptyId, pty.incarnationId ?? undefined) + } } } for (const leaf of this.leaves.values()) { if (leaf.worktreeId === worktreeId && leaf.tabId === tabId && leaf.ptyId) { - remember( - leaf.ptyId, - this.ptysById.get(leaf.ptyId)?.incarnationId ?? - this.getPersistedTerminalIncarnation(worktreeId, tabId, leaf.leafId, leaf.ptyId) ?? - undefined + const livePty = this.ptysById.get(leaf.ptyId) + const persistedIncarnation = this.getPersistedTerminalIncarnation( + worktreeId, + tabId, + leaf.leafId, + leaf.ptyId ) + if (livePty?.incarnationId || persistedPtyIds.has(leaf.ptyId)) { + remember(leaf.ptyId, livePty?.incarnationId ?? persistedIncarnation ?? undefined) + } } } return [...targets].map(([ptyId, incarnationId]) => ({ @@ -29693,6 +29713,32 @@ export class OrcaRuntimeService { return addressedPtyStopped } + private async assertExplicitCloseTargetsCanStopExactly( + targets: readonly { ptyId: string; incarnationId?: string }[], + addressedPtyId: string + ): Promise { + if ( + !this.ptyController?.stopAndWait || + !targets.some( + (target) => target.ptyId === addressedPtyId && target.incarnationId !== undefined + ) || + targets.some((target) => !target.incarnationId) + ) { + throw new Error('terminal_incarnation_fence_unavailable') + } + const deadlineMs = Date.now() + EXPLICIT_TERMINAL_CLOSE_STOP_TIMEOUT_MS + for (const { ptyId, incarnationId } of targets) { + if ( + (await this.ptyController.supportsIncarnationAddressedStop?.(ptyId, { + deadlineMs, + expectedIncarnationId: incarnationId + })) === false + ) { + throw new Error('terminal_incarnation_fence_unavailable') + } + } + } + private buildTerminalTabCloseExpectation( handle: string, ptyId: string, @@ -29969,6 +30015,7 @@ export class OrcaRuntimeService { : this.countLeavesInTab(tabId) if (siblingCount <= 1 && surface && this.tabs.has(tabId) && this.notifier?.closeTerminalTab) { const ptyIdsToKill = this.getPtyTargetsForExplicitTabClose(pty.pty.worktreeId, tabId) + await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, pty.pty.ptyId) try { await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { localPtyTeardownOwnedExternally: true, @@ -29989,6 +30036,7 @@ export class OrcaRuntimeService { } if (siblingCount <= 1 && !surface && pty.pty.tabId && this.notifier?.closeTerminalTab) { const ptyIdsToKill = this.getPtyTargetsForExplicitTabClose(pty.pty.worktreeId, tabId) + await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, pty.pty.ptyId) await this.notifier.closeTerminalTab(tabId, { localPtyTeardownOwnedExternally: true, expectedTerminal @@ -30000,23 +30048,26 @@ export class OrcaRuntimeService { ) return this.describeTerminalClose(handle, tabId, pty.pty.ptyId, ptyKilled) } + const ptyIdsToKill = [ + { + ptyId: pty.pty.ptyId, + ...(expectedTerminal.incarnationId + ? { incarnationId: expectedTerminal.incarnationId } + : {}) + } + ] + await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, pty.pty.ptyId) const ptyKilled = await this.stopExplicitlyClosedTabPtys( - [ - { - ptyId: pty.pty.ptyId, - ...(expectedTerminal.incarnationId - ? { incarnationId: expectedTerminal.incarnationId } - : {}) - } - ], + ptyIdsToKill, pty.pty.ptyId, expectedTerminal ) - if (!ptyKilled || siblingCount <= 1) { + if (siblingCount <= 1) { if (surface) { // Why: paired viewers keep ended streams mounted until the HUB publishes removal, so explicit close uses the durable host-tab transaction instead of viewer-local exit handling. try { await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { + localPtyTeardownOwnedExternally: true, expectedTerminal }) } catch (error) { @@ -30049,6 +30100,9 @@ export class OrcaRuntimeService { } ] : [] + if (leaf.ptyId) { + await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, leaf.ptyId) + } if (siblingCount <= 1 && this.notifier?.closeTerminalTab) { await this.notifier.closeTerminalTab(leaf.tabId, { localPtyTeardownOwnedExternally: true, @@ -30058,7 +30112,7 @@ export class OrcaRuntimeService { const ptyKilled = leaf.ptyId ? await this.stopExplicitlyClosedTabPtys(ptyIdsToKill, leaf.ptyId, expectedTerminal) : false - if (siblingCount > 1 ? !ptyKilled : !this.notifier?.closeTerminalTab) { + if (siblingCount <= 1 && !this.notifier?.closeTerminalTab) { this.notifier?.closeTerminal(leaf.tabId, leaf.paneRuntimeId) } return this.describeTerminalClose(handle, leaf.tabId, leaf.ptyId ?? null, ptyKilled) @@ -30110,6 +30164,7 @@ export class OrcaRuntimeService { leafId ) const ptyIdsToKill = this.getPtyTargetsForExplicitTabClose(pty.pty.worktreeId, tabId) + await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, pty.pty.ptyId) await this.closeMobileSessionTab(`id:${pty.pty.worktreeId}`, tabId, { reason: 'user', localPtyTeardownOwnedExternally: true, @@ -30127,6 +30182,9 @@ export class OrcaRuntimeService { const { leaf } = this.getLiveLeafForHandle(handle) const expectedTerminal = this.buildLeafTerminalTabCloseExpectation(handle, leaf) const ptyIdsToKill = this.getPtyTargetsForExplicitTabClose(leaf.worktreeId, leaf.tabId) + if (leaf.ptyId) { + await this.assertExplicitCloseTargetsCanStopExactly(ptyIdsToKill, leaf.ptyId) + } await this.closeMobileSessionTab(`id:${leaf.worktreeId}`, leaf.tabId, { reason: 'user', localPtyTeardownOwnedExternally: true, @@ -30257,7 +30315,11 @@ export class OrcaRuntimeService { if (result.wslDistro) { this.preparePtyExecutionContext(result.id, result.wslDistro) } - this.registerPty(result.id, workspace.id, workspace.connectionId) + this.registerPty(result.id, workspace.id, workspace.connectionId, { + tabId: parentTabId, + leafId, + ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) + }) const createdPty = this.getOrCreatePtyWorktreeRecord(result.id) if (createdPty) { createdPty.tabId = parentTabId @@ -30334,20 +30396,14 @@ export class OrcaRuntimeService { try { stopped = (await this.ptyController.stopAndWait?.(result.id, { - deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS + deadlineMs: Date.now() + REJECTED_SPLIT_PTY_STOP_TIMEOUT_MS, + ...(result.incarnationId ? { expectedIncarnationId: result.incarnationId } : {}) })) ?? false } catch { - // Best-effort fallback below preserves the original split authority error. - } - if (!stopped) { - try { - this.ptyController.kill(result.id) - } catch { - // Best-effort cleanup; retirement below still runs and the original error still throws. - } + // Exact cleanup remains unverifiable; preserve the original split authority error. } try { - this.ptyController.retireRejectedPty?.(result.id, stopped) + this.ptyController.retireRejectedPty?.(result.id, stopped, result.incarnationId) } catch { // Best-effort cleanup; preserve the original split authority error. } diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 7a1e38da1c2..c6e02e1d4e2 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -58,6 +58,7 @@ import { parsePtyStartupIngressIntent, type PtyIngressEmission } from '../shared/pty-startup-ingress' +import { PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION } from '../shared/pty-incarnation' import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' import { expandWindowsPathEnvironmentVariables } from '../shared/windows-environment-expansion' @@ -917,7 +918,8 @@ export class PtyHandler { this.dispatcher.onRequest('pty.getCapabilities', async () => ({ startupIngressVersion: PTY_STARTUP_INGRESS_VERSION, agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION, - agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION + agentSessionCreateOperationVersion: AGENT_SESSION_CREATE_OPERATION_PROTOCOL_VERSION, + incarnationAddressedShutdownVersion: PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION })) this.dispatcher.onRequest('pty.listProcesses', () => this.listProcesses()) this.dispatcher.onRequest('pty.getDefaultShell', async () => resolveDefaultShell()) diff --git a/src/shared/local-build-compatibility-contract.json b/src/shared/local-build-compatibility-contract.json index aa2cc016f4b..7c918c73a0d 100644 --- a/src/shared/local-build-compatibility-contract.json +++ b/src/shared/local-build-compatibility-contract.json @@ -3,9 +3,9 @@ "appId": "com.stablyai.orca", "stateSchemaVersion": 1, "readableStateSchemaVersions": [1], - "daemonProtocolVersion": 35, + "daemonProtocolVersion": 36, "attachableDaemonProtocolVersions": [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36 ] } diff --git a/src/shared/local-build-compatibility-contract.ts b/src/shared/local-build-compatibility-contract.ts index 4ff84aa6dfa..7e61c309180 100644 --- a/src/shared/local-build-compatibility-contract.ts +++ b/src/shared/local-build-compatibility-contract.ts @@ -3,9 +3,9 @@ export const LOCAL_BUILD_COMPATIBILITY_CONTRACT = { appId: 'com.stablyai.orca', stateSchemaVersion: 1, readableStateSchemaVersions: [1], - daemonProtocolVersion: 35, + daemonProtocolVersion: 36, attachableDaemonProtocolVersions: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, - 27, 28, 29, 30, 31, 32, 33, 34, 35 + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36 ] } as const diff --git a/src/shared/pty-incarnation.ts b/src/shared/pty-incarnation.ts index a68ccd82e94..84477d98a69 100644 --- a/src/shared/pty-incarnation.ts +++ b/src/shared/pty-incarnation.ts @@ -1,5 +1,7 @@ export type PtyIncarnationId = string +export const PTY_INCARNATION_ADDRESSED_SHUTDOWN_VERSION = 1 + export function isPtyIncarnationId(value: unknown): value is PtyIncarnationId { return typeof value === 'string' && value.length > 0 && value.length <= 128 }