mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 16:17:51 +00:00
fix(terminal): park SSH worktrees like local ones (C1 retention, slice A)
SSH ptys were blanket-excluded from hidden-view parking, so a hidden SSH worktree retained every pane forever (C1: renderer heap climbs to the V8 ceiling). SSH bytes transit local main — fact-mode watchers already cover them, and main keeps a headless model served over pty:getMainBufferSnapshot that the SSH reattach path never consulted. - isParkRestorableTerminalPty: snapshot-backed OR (SSH + policy); threaded through both park verdicts, both selectors, watcher coverage, and the watcher start guard. Remote-runtime/fail-open/foreign/null unchanged. - Parked-SSH reveal paints from main's headless model (dimension-matched, ~5k rows) and degrades to the relay 100KiB replay unless the snapshot is a non-empty source==='headless' payload — never a blank/stale paint. - Kill switch: settings.terminalSshViewParking (default on). DESIGN.md records the approved plan and the H1 magnitude non-claim. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -272,6 +272,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
|
||||
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
|
||||
const terminalSshParkingEnabled = useAppStore((s) => s.settings?.terminalSshViewParking !== false)
|
||||
const terminalTitleSnapshotAuthorityEnabled = useAppStore((s) =>
|
||||
isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: s.settings,
|
||||
@@ -883,6 +884,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
pendingStartupByTabId,
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
nowMs,
|
||||
restorePolicy: { sshParkingEnabled: terminalSshParkingEnabled },
|
||||
...overrides
|
||||
})
|
||||
// Why: a worktree with any watcher-uncoverable tab must never park, or it goes silent for bells/titles/completions (sank the first parking attempt).
|
||||
@@ -931,6 +933,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
tabsByWorktree,
|
||||
terminalParkingEnabled,
|
||||
terminalParkingRevision,
|
||||
terminalSshParkingEnabled,
|
||||
workspaceSurfaces
|
||||
])
|
||||
// Why: gate on workspaceSessionReady so TerminalPane doesn't mount and spawn a duplicate PTY before reconnectPersistedTerminals() finishes.
|
||||
|
||||
@@ -243,6 +243,10 @@ import {
|
||||
scheduleHiddenOutputRestore
|
||||
} from './hidden-output-restore-scheduler'
|
||||
import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback'
|
||||
import {
|
||||
decideSshReattachPaintSource,
|
||||
shouldFetchSshReattachModelSnapshot
|
||||
} from './ssh-reattach-model-restore'
|
||||
import {
|
||||
getExecutionHostIdForWorktree,
|
||||
getSettingsForWorktreeRuntimeOwner
|
||||
@@ -7614,6 +7618,37 @@ export function connectPanePty(
|
||||
registerPaneSerializerFor(ptyId)
|
||||
|
||||
let reattachPayloadApplied = !hasStructuralReplay
|
||||
// Why (C1 SSH parking): main's headless model holds ~5k rows for SSH ptys
|
||||
// while the relay replay is a 100KiB raw-byte tail; prefer the model on
|
||||
// reveal. Only a non-empty 'headless'-sourced snapshot qualifies — the
|
||||
// renderer-serializer fallback has no mounted xterm after a park.
|
||||
const tryApplySshMainModelReattachSnapshot = async (): Promise<boolean> => {
|
||||
const sshParkingEnabled = useAppStore.getState().settings?.terminalSshViewParking !== false
|
||||
if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) {
|
||||
return false
|
||||
}
|
||||
const snapshot = await window.api.pty
|
||||
.getMainBufferSnapshot(ptyId, {
|
||||
scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback)
|
||||
})
|
||||
.catch(() => null)
|
||||
if (!isCurrentReattachPayload()) {
|
||||
// Why: a superseded attempt must not paint, but reporting true would skip the replay fallback of the live attempt.
|
||||
return true
|
||||
}
|
||||
if (
|
||||
!snapshot ||
|
||||
decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) !==
|
||||
'main-model-snapshot'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
rememberReattachPayloadAgentSignal(snapshot.data, { fullScreenReplay: true })
|
||||
kittyKeyboardModes.scanReplay(snapshot.data)
|
||||
await applyMainBufferSnapshot(snapshot)
|
||||
sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration)
|
||||
return true
|
||||
}
|
||||
const applyReattachPayload = async (): Promise<void> => {
|
||||
if (!isCurrentReattachPayload()) {
|
||||
return
|
||||
@@ -7659,6 +7694,15 @@ export function connectPanePty(
|
||||
}
|
||||
}
|
||||
} else if (connectResult?.replay) {
|
||||
if (await tryApplySshMainModelReattachSnapshot()) {
|
||||
if (connectResult.coldRestore && !isRemoteRuntimePtyId(ptyId)) {
|
||||
window.api.pty.ackColdRestore(ptyId)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isCurrentReattachPayload()) {
|
||||
return
|
||||
}
|
||||
rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true })
|
||||
// Relay replay may overlap xterm's pre-disconnect content; clear first to avoid duplication.
|
||||
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
decideSshReattachPaintSource,
|
||||
shouldFetchSshReattachModelSnapshot
|
||||
} from './ssh-reattach-model-restore'
|
||||
|
||||
const SSH_PTY_ID = 'ssh:conn-1@@relay-pty-1'
|
||||
const LOCAL_PTY_ID = 'repo::/worktree@@session-1'
|
||||
|
||||
describe('shouldFetchSshReattachModelSnapshot', () => {
|
||||
it('fetches only for SSH ptys with SSH parking enabled', () => {
|
||||
expect(
|
||||
shouldFetchSshReattachModelSnapshot({ ptyId: SSH_PTY_ID, sshParkingEnabled: true })
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldFetchSshReattachModelSnapshot({ ptyId: SSH_PTY_ID, sshParkingEnabled: false })
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldFetchSshReattachModelSnapshot({ ptyId: LOCAL_PTY_ID, sshParkingEnabled: true })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideSshReattachPaintSource', () => {
|
||||
const headless = { data: 'screen', source: 'headless' as const }
|
||||
|
||||
it('paints from the main model only for a non-empty headless snapshot', () => {
|
||||
expect(
|
||||
decideSshReattachPaintSource({
|
||||
ptyId: SSH_PTY_ID,
|
||||
sshParkingEnabled: true,
|
||||
snapshot: headless
|
||||
})
|
||||
).toBe('main-model-snapshot')
|
||||
})
|
||||
|
||||
it('degrades to relay replay on null, renderer-sourced, sourceless, or empty snapshots', () => {
|
||||
for (const snapshot of [
|
||||
null,
|
||||
{ data: 'screen', source: 'renderer' as const },
|
||||
{ data: 'screen' },
|
||||
{ data: '', source: 'headless' as const }
|
||||
]) {
|
||||
expect(
|
||||
decideSshReattachPaintSource({ ptyId: SSH_PTY_ID, sshParkingEnabled: true, snapshot })
|
||||
).toBe('relay-replay')
|
||||
}
|
||||
})
|
||||
|
||||
it('never upgrades when the kill switch is off or the pty is not SSH', () => {
|
||||
expect(
|
||||
decideSshReattachPaintSource({
|
||||
ptyId: SSH_PTY_ID,
|
||||
sshParkingEnabled: false,
|
||||
snapshot: headless
|
||||
})
|
||||
).toBe('relay-replay')
|
||||
expect(
|
||||
decideSshReattachPaintSource({
|
||||
ptyId: LOCAL_PTY_ID,
|
||||
sshParkingEnabled: true,
|
||||
snapshot: headless
|
||||
})
|
||||
).toBe('relay-replay')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
|
||||
export type SshReattachPaintSource = 'main-model-snapshot' | 'relay-replay'
|
||||
|
||||
export type SshReattachModelSnapshot = {
|
||||
data: string
|
||||
source?: 'headless' | 'renderer'
|
||||
}
|
||||
|
||||
/**
|
||||
* Which payload paints an SSH reattach (C1 SSH-parking design gate). Only
|
||||
* main's headless model is trusted: a 'renderer'-sourced snapshot serializes a
|
||||
* mounted xterm, which no longer exists once the pane parked — anything but a
|
||||
* non-empty headless snapshot degrades to the relay replay, never a blank paint.
|
||||
*/
|
||||
export function decideSshReattachPaintSource(args: {
|
||||
ptyId: string
|
||||
sshParkingEnabled: boolean
|
||||
snapshot: SshReattachModelSnapshot | null
|
||||
}): SshReattachPaintSource {
|
||||
if (!args.sshParkingEnabled || parseAppSshPtyId(args.ptyId) === null) {
|
||||
return 'relay-replay'
|
||||
}
|
||||
if (!args.snapshot || args.snapshot.source !== 'headless' || args.snapshot.data.length === 0) {
|
||||
return 'relay-replay'
|
||||
}
|
||||
return 'main-model-snapshot'
|
||||
}
|
||||
|
||||
/** Skip the snapshot fetch entirely when the paint could never use it. */
|
||||
export function shouldFetchSshReattachModelSnapshot(args: {
|
||||
ptyId: string
|
||||
sshParkingEnabled: boolean
|
||||
}): boolean {
|
||||
return args.sshParkingEnabled && parseAppSshPtyId(args.ptyId) !== null
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
canParkTerminalWorktreeRenderers,
|
||||
getTerminalTabColdParkRecheckDelayMs,
|
||||
getTerminalWorktreeColdParkRecheckDelayMs,
|
||||
isParkRestorableTerminalPty,
|
||||
isSnapshotBackedTerminalPty,
|
||||
selectColdParkedTerminalTabs,
|
||||
selectColdParkedTerminalWorktrees
|
||||
@@ -46,6 +47,32 @@ describe('isSnapshotBackedTerminalPty', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isParkRestorableTerminalPty', () => {
|
||||
const worktreeId = 'repo::/worktree'
|
||||
const sshPolicy = { sshParkingEnabled: true }
|
||||
|
||||
it('accepts every snapshot-backed pty regardless of policy', () => {
|
||||
expect(isParkRestorableTerminalPty(`${worktreeId}@@session-1`, worktreeId)).toBe(true)
|
||||
expect(isParkRestorableTerminalPty(`${worktreeId}@@session-1`, worktreeId, sshPolicy)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts SSH ptys only when the SSH-parking policy is enabled', () => {
|
||||
expect(isParkRestorableTerminalPty('ssh:ssh-1@@pty-1', worktreeId, sshPolicy)).toBe(true)
|
||||
expect(isParkRestorableTerminalPty('ssh:ssh-1@@pty-1', worktreeId)).toBe(false)
|
||||
expect(
|
||||
isParkRestorableTerminalPty('ssh:ssh-1@@pty-1', worktreeId, { sshParkingEnabled: false })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects remote-runtime, fail-open, foreign, and null ptys under every policy', () => {
|
||||
for (const ptyId of ['remote:env-1@@terminal-1', 'pty-local-detached', 'other@@s-1', null]) {
|
||||
expect(isParkRestorableTerminalPty(ptyId, worktreeId, sshPolicy)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('canParkTerminalWorktreeRenderers', () => {
|
||||
const hiddenSinceMs = 1_000
|
||||
const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
|
||||
@@ -65,6 +92,24 @@ describe('canParkTerminalWorktreeRenderers', () => {
|
||||
expect(canParkTerminalWorktreeRenderers(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('parks a hidden SSH worktree only under the SSH restore policy', () => {
|
||||
const sshArgs = {
|
||||
...base,
|
||||
terminalTabs: [{ id: 'tab-1', ptyId: 'ssh:conn-1@@pty-1' }]
|
||||
}
|
||||
expect(canParkTerminalWorktreeRenderers(sshArgs)).toBe(false)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({ ...sshArgs, restorePolicy: { sshParkingEnabled: true } })
|
||||
).toBe(true)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...sshArgs,
|
||||
terminalTabs: [...sshArgs.terminalTabs, { id: 'tab-2', ptyId: 'remote:env-1@@t-1' }],
|
||||
restorePolicy: { sshParkingEnabled: true }
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a previously mounted v19 terminal eligible for ordinary parking', () => {
|
||||
const legacyPtyId = 'repo::/worktree@@session-1'
|
||||
clearTerminalProviderSnapshotCapabilities()
|
||||
|
||||
@@ -67,6 +67,26 @@ export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: st
|
||||
return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId
|
||||
}
|
||||
|
||||
export type TerminalParkRestorePolicy = {
|
||||
/** settings.terminalSshViewParking !== false — the C1 SSH-parking kill switch. */
|
||||
sshParkingEnabled?: boolean
|
||||
}
|
||||
|
||||
// Why: SSH bytes transit local main, so main's headless model (served over
|
||||
// pty:getMainBufferSnapshot) can re-hydrate a parked SSH reveal, with the
|
||||
// relay's replay buffer as fallback — fact-mode watchers cover side effects
|
||||
// either way. Remote-runtime ptys never transit main; they stay un-parkable.
|
||||
export function isParkRestorableTerminalPty(
|
||||
ptyId: string | null,
|
||||
worktreeId: string,
|
||||
policy?: TerminalParkRestorePolicy
|
||||
): boolean {
|
||||
if (isSnapshotBackedTerminalPty(ptyId, worktreeId)) {
|
||||
return true
|
||||
}
|
||||
return policy?.sshParkingEnabled === true && ptyId !== null && parseAppSshPtyId(ptyId) !== null
|
||||
}
|
||||
|
||||
export function canParkTerminalWorktreeRenderers(args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly ColdParkableTerminalTab[]
|
||||
@@ -80,6 +100,7 @@ export function canParkTerminalWorktreeRenderers(args: {
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
restorePolicy?: TerminalParkRestorePolicy
|
||||
}): boolean {
|
||||
if (
|
||||
!args.parkingEnabled ||
|
||||
@@ -103,7 +124,7 @@ export function canParkTerminalWorktreeRenderers(args: {
|
||||
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
|
||||
return false
|
||||
}
|
||||
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
|
||||
return isParkRestorableTerminalPty(tab.ptyId, args.worktreeId, args.restorePolicy)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,6 +135,7 @@ export function canParkTerminalTabRenderer(args: {
|
||||
parkingEnabled: boolean
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
restorePolicy?: TerminalParkRestorePolicy
|
||||
}): boolean {
|
||||
const tab = args.terminalTab
|
||||
if (
|
||||
@@ -133,7 +155,7 @@ export function canParkTerminalTabRenderer(args: {
|
||||
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
|
||||
return false
|
||||
}
|
||||
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
|
||||
return isParkRestorableTerminalPty(tab.ptyId, args.worktreeId, args.restorePolicy)
|
||||
}
|
||||
|
||||
type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number }
|
||||
@@ -197,6 +219,7 @@ export function selectColdParkedTerminalWorktrees(
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
parkingEnabled: boolean
|
||||
nowMs: number
|
||||
restorePolicy?: TerminalParkRestorePolicy
|
||||
} & TerminalColdParkPolicyOverrides
|
||||
): Set<string> {
|
||||
if (!args.parkingEnabled) {
|
||||
@@ -212,7 +235,8 @@ export function selectColdParkedTerminalWorktrees(
|
||||
pendingStartupByTabId: args.pendingStartupByTabId,
|
||||
parkingEnabled: args.parkingEnabled,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs
|
||||
coldParkDelayMs,
|
||||
...(args.restorePolicy ? { restorePolicy: args.restorePolicy } : {})
|
||||
})
|
||||
) {
|
||||
continue
|
||||
@@ -233,6 +257,7 @@ export function selectColdParkedTerminalTabs(
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
parkingEnabled: boolean
|
||||
nowMs: number
|
||||
restorePolicy?: TerminalParkRestorePolicy
|
||||
} & TerminalColdParkPolicyOverrides
|
||||
): Set<string> {
|
||||
if (!args.parkingEnabled) {
|
||||
@@ -249,7 +274,8 @@ export function selectColdParkedTerminalTabs(
|
||||
pendingStartupByTabId: args.pendingStartupByTabId,
|
||||
parkingEnabled: args.parkingEnabled,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs
|
||||
coldParkDelayMs,
|
||||
...(args.restorePolicy ? { restorePolicy: args.restorePolicy } : {})
|
||||
})
|
||||
) {
|
||||
continue
|
||||
|
||||
@@ -79,6 +79,7 @@ type MockStoreState = {
|
||||
}
|
||||
>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
settings: { terminalSshViewParking?: boolean } | null
|
||||
clearTabLaunchAgent: ReturnType<typeof vi.fn>
|
||||
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
setTabLayout: ReturnType<typeof vi.fn>
|
||||
@@ -135,6 +136,7 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
settings: null,
|
||||
clearTabLaunchAgent: vi.fn(),
|
||||
clearRuntimePaneTitle: vi.fn(),
|
||||
setTabLayout: vi.fn(),
|
||||
@@ -204,10 +206,9 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID })
|
||||
})
|
||||
|
||||
it('never starts watchers for remote-runtime or SSH PTYs', () => {
|
||||
it('never starts watchers for remote-runtime PTYs', () => {
|
||||
capturePanes([
|
||||
{ ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
{ ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }
|
||||
])
|
||||
syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] })
|
||||
|
||||
@@ -217,6 +218,22 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
})
|
||||
|
||||
it('starts watchers for SSH PTYs (C1 SSH parking, default on)', () => {
|
||||
capturePanes([{ ptyId: 'ssh:conn-1@@pty-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked({ tabs: [{ id: TAB_ID, ptyId: 'ssh:conn-1@@pty-1' }] })
|
||||
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[0].options).toMatchObject({ ptyId: 'ssh:conn-1@@pty-1' })
|
||||
})
|
||||
|
||||
it('never starts watchers for SSH PTYs when terminalSshViewParking is off', () => {
|
||||
mockStoreState.settings = { terminalSshViewParking: false }
|
||||
capturePanes([{ ptyId: 'ssh:conn-1@@pty-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] })
|
||||
|
||||
expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps existing watchers across repeated syncs of the same parked state', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
@@ -702,6 +719,28 @@ describe('terminal-parked-tab-watchers', () => {
|
||||
})
|
||||
|
||||
it('rejects a capture containing a PTY without snapshot backing', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
// Why foreign-worktree id: minted under another worktree, never restorable.
|
||||
{ ptyId: 'other::wt@@session-9', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts an SSH PTY under the default C1 SSH-parking policy', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an SSH PTY when terminalSshViewParking is off', () => {
|
||||
mockStoreState.settings = { terminalSshViewParking: false }
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
|
||||
@@ -17,7 +17,10 @@ import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
|
||||
import { subscribeToPtyExit } from './pty-dispatcher'
|
||||
import { discardPreHandlerPtyState } from './pty-pre-handler-buffer'
|
||||
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
|
||||
import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking'
|
||||
import {
|
||||
isParkRestorableTerminalPty,
|
||||
type TerminalParkRestorePolicy
|
||||
} from './terminal-hidden-view-parking'
|
||||
import {
|
||||
resolveTabTitleAfterPaneClose,
|
||||
shouldClearLaunchAgentForClosedPane
|
||||
@@ -94,9 +97,18 @@ function resolveParkedTerminalPaneCandidates(
|
||||
return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state)
|
||||
}
|
||||
|
||||
// Why: fact-mode watchers work for any pty whose bytes transit local main —
|
||||
// SSH included — so watcher coverage follows the park-restore policy, not the
|
||||
// stricter daemon-snapshot predicate.
|
||||
function parkRestorePolicyFromState(state: {
|
||||
settings: { terminalSshViewParking?: boolean } | null
|
||||
}): TerminalParkRestorePolicy {
|
||||
return { sshParkingEnabled: state.settings?.terminalSshViewParking !== false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether parked byte watchers can fully cover this tab's PTYs (every candidate
|
||||
* has a snapshot-backed PTY on a valid leaf). Hosts must refuse to park a tab
|
||||
* has a park-restorable PTY on a valid leaf). Hosts must refuse to park a tab
|
||||
* that fails this check, or bell/title/completion side effects silently drop.
|
||||
*/
|
||||
export function canWatcherCoverParkedTerminalTab(
|
||||
@@ -105,14 +117,16 @@ export function canWatcherCoverParkedTerminalTab(
|
||||
// Why: cold activation needs stronger snapshot support (view never mounted); ordinary parking can reattach a mounted view.
|
||||
isPtyEligible: ParkedTerminalPtyEligibility = allowSnapshotBackedPty
|
||||
): boolean {
|
||||
const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())
|
||||
const state = useAppStore.getState()
|
||||
const panes = resolveParkedTerminalPaneCandidates(tab, state)
|
||||
const restorePolicy = parkRestorePolicyFromState(state)
|
||||
return (
|
||||
panes.length > 0 &&
|
||||
panes.every(
|
||||
(pane) =>
|
||||
pane.ptyId !== null &&
|
||||
isTerminalLeafId(pane.leafId) &&
|
||||
isSnapshotBackedTerminalPty(pane.ptyId, worktreeId) &&
|
||||
isParkRestorableTerminalPty(pane.ptyId, worktreeId, restorePolicy) &&
|
||||
isPtyEligible(pane.ptyId)
|
||||
)
|
||||
)
|
||||
@@ -125,6 +139,7 @@ function startParkedTabWatchers(
|
||||
): void {
|
||||
const state = useAppStore.getState()
|
||||
const panes = resolveParkedTerminalPaneCandidates(tab, state)
|
||||
const restorePolicy = parkRestorePolicyFromState(state)
|
||||
const disposersByPtyId = new Map<string, () => void>()
|
||||
const paneIdByPtyId = new Map<string, number>()
|
||||
for (const pane of panes) {
|
||||
@@ -134,7 +149,7 @@ function startParkedTabWatchers(
|
||||
!ptyId ||
|
||||
disposersByPtyId.has(ptyId) ||
|
||||
!isTerminalLeafId(pane.leafId) ||
|
||||
!isSnapshotBackedTerminalPty(ptyId, worktreeId)
|
||||
!isParkRestorableTerminalPty(ptyId, worktreeId, restorePolicy)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ export function useTerminalTabColdParking(args: {
|
||||
const terminalParkingEnabled = useAppStore(
|
||||
(state) => state.settings?.terminalHiddenViewParking !== false
|
||||
)
|
||||
const terminalSshParkingEnabled = useAppStore(
|
||||
(state) => state.settings?.terminalSshViewParking !== false
|
||||
)
|
||||
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
|
||||
const terminalTabParkingTimersRef = useRef(new Map<string, number>())
|
||||
const parkVerdictRecordsRef = useRef(new Map<string, ParkVerdictFlipRecord>())
|
||||
@@ -147,6 +150,7 @@ export function useTerminalTabColdParking(args: {
|
||||
pendingStartupByTabId,
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
nowMs,
|
||||
restorePolicy: { sshParkingEnabled: terminalSshParkingEnabled },
|
||||
...overrides
|
||||
})
|
||||
// Why: a tab the byte watchers cannot cover (no capture, no layout
|
||||
@@ -196,6 +200,7 @@ export function useTerminalTabColdParking(args: {
|
||||
pendingStartupByTabId,
|
||||
shouldMeasureHiddenWorktree,
|
||||
terminalParkingEnabled,
|
||||
terminalSshParkingEnabled,
|
||||
terminalTabParkingRevision,
|
||||
terminalTabs,
|
||||
worktreeId
|
||||
|
||||
@@ -2848,6 +2848,8 @@ export type GlobalSettings = {
|
||||
terminalScopeHistoryByWorktree: boolean
|
||||
/** Kill switch for hidden terminal view parking: unmount long-hidden panes while a pane-less watcher keeps PTY side effects alive. */
|
||||
terminalHiddenViewParking?: boolean
|
||||
/** Kill switch for SSH terminal parking (C1): SSH panes park like local ones; reveal restores from main's headless model, falling back to relay replay. */
|
||||
terminalSshViewParking?: boolean
|
||||
/** Kill switch for main-process PTY side-effect authority; on (default) = title/bell/agent facts via pty:sideEffect channel, not renderer byte parsing. */
|
||||
terminalMainSideEffectAuthority?: boolean
|
||||
/** Kill switch for main's hidden-delivery gate (Phase 4): drops PTY bytes to hidden views after model ingestion; requires terminalMainSideEffectAuthority. */
|
||||
|
||||
Reference in New Issue
Block a user