Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/main/ipc/pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8411,5 +8411,43 @@ describe('registerPtyHandlers', () => {
source: 'headless'
})
})

// STA-1282 gate #5 (xterm-addon.boundary-containment): the eviction remount
// fail-open counter must tell a structural replay failure from a nil mirror.
// The old handler swallowed serialization errors to null, collapsing both
// cases; these pin the corrected contract through the real IPC handler shape.
it('rejects (does not swallow to null) when serialization throws, so a structural failure is distinguishable', async () => {
const runtime = {
setPtyController: vi.fn(),
serializeHiddenOutputRecoveryBuffer: vi.fn().mockRejectedValue(new Error('mirror boom'))
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)

await expect(
handlers.get('pty:getMainBufferSnapshot')!(null, { id: 'pty-1' })
).rejects.toThrow('mirror boom')
})

it('resolves null (nil mirror, not a failure) when there is no runtime', async () => {
handlers.clear()
registerPtyHandlers(mainWindow as never)

await expect(
handlers.get('pty:getMainBufferSnapshot')!(null, { id: 'pty-1' })
).resolves.toBeNull()
})

it('resolves null without serializing for an empty/invalid id (nil mirror, not an error)', async () => {
const runtime = {
setPtyController: vi.fn(),
serializeHiddenOutputRecoveryBuffer: vi.fn()
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)

await expect(handlers.get('pty:getMainBufferSnapshot')!(null, { id: '' })).resolves.toBeNull()
expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled()
})
})
})
23 changes: 8 additions & 15 deletions src/main/ipc/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { Store } from '../persistence'
import type { GlobalSettings, TuiAgent } from '../../shared/types'
import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import type { PtyMainBufferSnapshotPayload } from '../../shared/pty-main-buffer-snapshot'
import {
isWslShellName,
resolveLocalWindowsTerminalRuntimeOptions
Expand Down Expand Up @@ -2499,25 +2500,17 @@ export function registerPtyHandlers(
async (
_event,
args: { id?: unknown; opts?: { scrollbackRows?: unknown } }
): Promise<{
data: string
cols: number
rows: number
cwd?: string | null
lastTitle?: string
seq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
} | null> => {
): Promise<PtyMainBufferSnapshotPayload | null> => {
if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) {
// Why: no runtime / bad id is a nil mirror, not a failure — resolves null
// so the STA-1282 eviction remount fail-open counter is not charged.
return null
}
const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows)
try {
return await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { scrollbackRows })
} catch {
return null
}
// Why: STA-1282 gate #5 — do NOT swallow serialization/RPC errors to null.
// A real error must REJECT so the renderer can tell a structural replay
// failure from a nil mirror; the old try/catch made both indistinguishable.
return await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { scrollbackRows })
}
)

Expand Down
22 changes: 22 additions & 0 deletions src/main/runtime/orca-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1495,6 +1495,28 @@ describe('OrcaRuntimeService', () => {
expect(shown.ptyId).toBe('pty-1')
})

it('stamps the main-buffer snapshot seq from the same counter as pty:data delivery (STA-1282 trim contract)', async () => {
const runtime = createRuntime()
const ptyId = 'pty-1'
// The renderer trims the reattach live-tail overlap by comparing the mirror
// snapshot seq against pty:data meta.seq. pty:data meta.seq is the cumulative
// byte count onPtyData returns (and stores in ptyOutputSequenceById); the
// mirror snapshot seq must share that exact domain or the trim would corrupt.
const firstChunk = 'seq-1\r\n'
const secondChunk = 'seq-2\r\nseq-3\r\n'
const seqAfterFirst = runtime.onPtyData(ptyId, firstChunk, 1)
const seqAfterSecond = runtime.onPtyData(ptyId, secondChunk, 2)
const cumulativeBytes = firstChunk.length + secondChunk.length

expect(seqAfterFirst).toBe(firstChunk.length)
expect(seqAfterSecond).toBe(cumulativeBytes)
expect(runtime.getPtyOutputSequence(ptyId)).toBe(cumulativeBytes)

const snapshot = await runtime.serializeMainTerminalBuffer(ptyId)
expect(snapshot?.seq).toBe(cumulativeBytes)
expect(snapshot?.seq).toBe(runtime.getPtyOutputSequence(ptyId))
})

it('surfaces stale terminal handles for stranded panes and recovers after same-pane wake', async () => {
const runtime = new OrcaRuntimeService(store)
const tabId = 'tab-1'
Expand Down
11 changes: 2 additions & 9 deletions src/preload/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from '../shared/hosted-review'
import type { NativeFileDropPayload } from '../shared/native-file-drop'
import type { ReadClipboardTextOptions } from '../shared/clipboard-text'
import type { PtyMainBufferSnapshotPayload } from '../shared/pty-main-buffer-snapshot'
import type { AppIdentity } from '../shared/app-identity'
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
import type { TaskSourceContext } from '../shared/task-source-context'
Expand Down Expand Up @@ -1166,15 +1167,7 @@ export type PreloadApi = {
getMainBufferSnapshot: (
id: string,
opts?: { scrollbackRows?: number }
) => Promise<{
data: string
cols: number
rows: number
cwd?: string | null
seq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
} | null>
) => Promise<PtyMainBufferSnapshotPayload | null>
getRendererDeliveryDebugSnapshot: () => Promise<{
pendingPtyCount: number
pendingChars: number
Expand Down
12 changes: 3 additions & 9 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { electronAPI } from '@electron-toolkit/preload'
import { preloadE2EConfig } from './e2e-config'
import { glApi } from './gitlab'
import type { AppIdentity } from '../shared/app-identity'
import type { PtyMainBufferSnapshotPayload } from '../shared/pty-main-buffer-snapshot'
import type { CliInstallStatus } from '../shared/cli-install-types'
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
Expand Down Expand Up @@ -842,15 +843,8 @@ const api = {
getMainBufferSnapshot: (
id: string,
opts?: { scrollbackRows?: number }
): Promise<{
data: string
cols: number
rows: number
cwd?: string | null
seq?: number
source?: 'headless' | 'renderer'
alternateScreen?: boolean
} | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
): Promise<PtyMainBufferSnapshotPayload | null> =>
ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),

getRendererDeliveryDebugSnapshot: (): Promise<{
pendingPtyCount: number
Expand Down
51 changes: 45 additions & 6 deletions src/renderer/src/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@/constants/terminal'
import { useAppStore } from '../store'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import { isTerminalPaneEvictionEnabled } from '../../../shared/terminal-pane-eviction-settings'
import { useAllWorktrees } from '../store/selectors'
import { getConnectionId } from '../lib/connection-context'
import { basename } from '../lib/path'
Expand Down Expand Up @@ -223,6 +224,8 @@ function Terminal(): React.JSX.Element | null {
const renderedActiveWorktreeId = activeWorktreeId
const activeView = useAppStore((s) => s.activeView)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const terminalPaneMountByTabId = useAppStore((s) => s.terminalPaneMountByTabId)
const terminalPaneEvictionEnabled = useAppStore((s) => isTerminalPaneEvictionEnabled(s.settings))
const activeTabId = useAppStore((s) => s.activeTabId)
const createTab = useAppStore((s) => s.createTab)
const closeTab = useAppStore((s) => s.closeTab)
Expand Down Expand Up @@ -753,15 +756,51 @@ function Terminal(): React.JSX.Element | null {
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
// with ptyId: null, and TerminalPane would call connectPanePty → pty:spawn,
// creating a duplicate PTY for the same tab.
if (renderedActiveWorktreeId && workspaceSessionReady) {
mountedWorktreeIdsRef.current.add(renderedActiveWorktreeId)
}
// Prune IDs of worktrees that no longer exist (deleted/removed)
const allWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id))
for (const id of mountedWorktreeIdsRef.current) {
if (!allWorktreeIds.has(id)) {
mountedWorktreeIdsRef.current.delete(id)
// STA-1282 worktree-dimension gate. Flag-OFF inertness: with eviction disabled
// (default) keep the pre-eviction MONOTONIC set — add the active worktree, prune
// only deleted ones — so switching away never unmounts a visited worktree's pane
// tree (byte-identical to today). With eviction ON the set is no longer
// monotonic: a worktree stays mounted only while it is active OR still has at
// least one policy-mounted (visible/warm/parked) terminal pane, so a fully
// aged-out worktree drops its whole pane tree (overlay layers included).
if (!terminalPaneEvictionEnabled) {
if (renderedActiveWorktreeId && workspaceSessionReady) {
mountedWorktreeIdsRef.current.add(renderedActiveWorktreeId)
}
for (const id of mountedWorktreeIdsRef.current) {
if (!allWorktreeIds.has(id)) {
mountedWorktreeIdsRef.current.delete(id)
}
}
} else {
const nextMountedWorktreeIds = new Set<string>()
if (renderedActiveWorktreeId && workspaceSessionReady) {
nextMountedWorktreeIds.add(renderedActiveWorktreeId)
}
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
if (allWorktreeIds.has(worktreeId) && tabs.some((tab) => terminalPaneMountByTabId[tab.id])) {
nextMountedWorktreeIds.add(worktreeId)
}
}
// Preserve the background-measurement set so transient layout measurement
// still mounts its worktree.
for (const id of measurableBackgroundWorktreeIdsRef.current) {
if (allWorktreeIds.has(id)) {
nextMountedWorktreeIds.add(id)
}
}
// Why: an Activity-page terminal portal shows a pane whose worktree may be
// fully aged-out (no policy-mounted tab). Its worktree must stay mounted so
// the overlay layer exists to host the portal — otherwise a terminal the user
// is viewing on the Activity page would render nothing (Tier 0 violation).
for (const portal of activityTerminalPortals) {
if (allWorktreeIds.has(portal.worktreeId)) {
nextMountedWorktreeIds.add(portal.worktreeId)
}
}
mountedWorktreeIdsRef.current = nextMountedWorktreeIds
}
const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout(
workspaceSurfaces.map((workspace) => workspace.id),
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/src/components/settings/ExperimentalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
MIN_AGENT_HIBERNATION_IDLE_MS,
getEffectiveAgentHibernationIdleMs
} from '@/lib/agent-hibernation-planner'
import { TerminalPaneEvictionExperimentalSetting } from './TerminalPaneEvictionExperimentalSetting'

export { getExperimentalPaneSearchEntries }

Expand Down Expand Up @@ -277,6 +278,11 @@ export function ExperimentalPane({
</SearchableSetting>
) : null}

<TerminalPaneEvictionExperimentalSetting
settings={settings}
updateSettings={updateSettings}
/>

{showNewWorktreeCardStyle ? (
<SearchableSetting
title={translate(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { GlobalSettings } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import { Label } from '../ui/label'
import { SearchableSetting } from './SearchableSetting'
import { NumberField, SettingsSwitch } from './SettingsFormControls'
import { getExperimentalSearchEntry } from './experimental-search'
import {
isTerminalPaneEvictionEnabled,
resolveTerminalPaneEvictionAfterMs,
resolveTerminalPaneEvictionWarmBudget,
TERMINAL_PANE_EVICTION_AFTER_MINUTES_MAX,
TERMINAL_PANE_EVICTION_AFTER_MINUTES_MIN,
TERMINAL_PANE_EVICTION_WARM_BUDGET_MAX,
TERMINAL_PANE_EVICTION_WARM_BUDGET_MIN
} from '../../../../shared/terminal-pane-eviction-settings'

const MS_PER_MINUTE = 60 * 1000

type TerminalPaneEvictionExperimentalSettingProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}

/** STA-1282: experimental, opt-in, default OFF terminal pane eviction — under
* Settings → Experimental, agent-hibernation precedent. */
export function TerminalPaneEvictionExperimentalSetting({
settings,
updateSettings
}: TerminalPaneEvictionExperimentalSettingProps): React.JSX.Element {
const enabled = isTerminalPaneEvictionEnabled(settings)
const warmBudget = resolveTerminalPaneEvictionWarmBudget(settings)
const afterMinutes = Math.round(resolveTerminalPaneEvictionAfterMs(settings) / MS_PER_MINUTE)

return (
<SearchableSetting
title={translate(
'auto.components.settings.ExperimentalPane.terminalPaneEviction.title',
'Free memory from hidden terminals'
)}
description={translate(
'auto.components.settings.ExperimentalPane.terminalPaneEviction.description',
'Unmounts terminal panes you have not looked at recently and restores them on demand, keeping heavy multi-agent workspaces fast.'
)}
keywords={getExperimentalSearchEntry().terminalPaneEviction.keywords}
className="space-y-3 py-2"
id="experimental-terminal-pane-eviction"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>
{translate(
'auto.components.settings.ExperimentalPane.terminalPaneEviction.title',
'Free memory from hidden terminals'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.terminalPaneEviction.copy',
'Unmounts terminal panes you have not viewed recently so a busy multi-agent workspace stays fast. Their processes keep running and the pane restores from the live mirror when you open it again. Experimental while we tune restore fidelity for panes with a running agent.'
)}
</p>
</div>
<SettingsSwitch
checked={enabled}
ariaLabel={translate(
'auto.components.settings.ExperimentalPane.terminalPaneEviction.toggleLabel',
'Toggle free memory from hidden terminals'
)}
onChange={() => updateSettings({ experimentalTerminalPaneEviction: !enabled })}
/>
</div>
{enabled ? (
<>
<NumberField
label={translate(
'auto.components.settings.ExperimentalPane.terminalPaneEviction.warmBudgetLabel',
'Terminals kept ready'
)}
description={translate(
'auto.components.settings.ExperimentalPane.terminalPaneEviction.warmBudgetDescription',
'How many recently-viewed hidden terminals stay mounted before older ones are unmounted.'
)}
value={warmBudget}
min={TERMINAL_PANE_EVICTION_WARM_BUDGET_MIN}
max={TERMINAL_PANE_EVICTION_WARM_BUDGET_MAX}
step={1}
onChange={(value) => updateSettings({ terminalPaneEvictionWarmBudget: value })}
/>
<NumberField
label={translate(
'auto.components.settings.ExperimentalPane.terminalPaneEviction.afterMinutesLabel',
'Unmount after'
)}
description={translate(
'auto.components.settings.ExperimentalPane.terminalPaneEviction.afterMinutesDescription',
'A hidden terminal is unmounted once it has been out of view for this long.'
)}
value={afterMinutes}
min={TERMINAL_PANE_EVICTION_AFTER_MINUTES_MIN}
max={TERMINAL_PANE_EVICTION_AFTER_MINUTES_MAX}
step={1}
suffix={translate(
'auto.components.settings.ExperimentalPane.terminalPaneEviction.afterMinutesSuffix',
'minutes'
)}
onChange={(minutes) => updateSettings({ terminalPaneEvictionAfterMinutes: minutes })}
/>
</>
) : null}
</SearchableSetting>
)
}
Loading
Loading