fix(automations): a slept server run opens its own tab from a paired desktop (#32132)

* fix(automations): a slept headless run opens from a paired desktop, and a half-failed sleep keeps its pane link

A paired desktop now asks the server to open a run whose pane retention slept (its PTY id
is dropped, its pane kept), instead of only opening the workspace. Retention finishes a
sleep that stopped the run's PTY and then failed, as the host's sleep state still holds it,
instead of forgetting the run's pane on the next sweep.

* test: use typed automation retention drain hook

* test(automations): fail the real resume-record write, and recover a slept run whose record failed to save

* revert(automations): drop the half-failed sleep recovery, which no real path reaches

A real headless serve keeps a slept run's pane resolving to its stopped PTY, so retention's
retry already re-sleeps and keeps the pane link; a failed resume-record write is swallowed
inside the sleep. Only a mocked null PTY lookup reached the runSleepHeld branch.
This commit is contained in:
Jinwoo Hong
2026-10-22 22:21:51 -04:00
committed by GitHub
parent 6768988ed6
commit 3ef3ab95fa
4 changed files with 34 additions and 3 deletions
@@ -105,6 +105,7 @@ export function resolveAutomationRunTerminalTarget(
const mirroredTabId = toWebTerminalSurfaceTabId(tabId)
const layout = terminals.terminalLayoutsByTabId[mirroredTabId]
const mirroredPtyId = layout?.ptyIdsByLeafId?.[parsed.leafId]
// Why a live PTY only: a slept run's pane is asked of the server, which knows if it still exists.
if (
!run.terminalPtyId ||
!terminals.hasTerminalTab(mirroredTabId) ||
@@ -91,6 +91,21 @@ describe('automation run view state', () => {
})
})
it('keeps View run for a paired server run whose pane slept, which only the server can open', () => {
const slept = makeRun({ terminalPtyId: null })
expect(
getAutomationRunViewState({
run: slept,
workspaceExists: true,
terminalTargetExists: false,
terminalOnPairedServer: true
})
).toMatchObject({ availability: 'terminal', actionLabel: 'View run', canOpen: true })
expect(
getAutomationRunViewState({ run: slept, workspaceExists: true, terminalTargetExists: false })
).toMatchObject({ availability: 'workspace', actionLabel: 'Open workspace' })
})
it('resumes the workspace only when there is no exact terminal identity', () => {
expect(
getAutomationRunViewState({
@@ -61,6 +61,8 @@ export function getAutomationRunViewState({
terminalOnPairedServer?: boolean
}): AutomationRunViewState {
const hasTerminalIdentity = Boolean(run.terminalPaneKey && run.terminalPtyId)
// Why the pane alone: a slept run drops its PTY id but keeps its pane, which the server resumes.
const hasPaneIdentity = Boolean(run.terminalPaneKey)
if (run.workspaceId && workspaceExists && terminalTargetExists) {
return {
availability: 'terminal',
@@ -70,7 +72,7 @@ export function getAutomationRunViewState({
}
}
if (run.workspaceId && workspaceExists && hasTerminalIdentity && terminalOnPairedServer) {
if (run.workspaceId && workspaceExists && hasPaneIdentity && terminalOnPairedServer) {
return {
availability: 'terminal',
actionLabel: 'View run',
@@ -95,13 +95,13 @@ function storeState(mirrored: boolean): Record<string, unknown> {
}
}
function openRun(): void {
function openRun(target: AutomationRun = run): void {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the action reads only these members.
const context = {
store: { repoForRow: () => undefined, worktreeForRow: () => worktree },
list: { selectedRow: { key: 'row' } }
} as unknown as AutomationsPageActionContext
createAutomationRunWorkspaceAction(context)(run)
createAutomationRunWorkspaceAction(context)(target)
}
describe('opening a paired-server automation run', () => {
@@ -142,4 +142,17 @@ describe('opening a paired-server automation run', () => {
expect(mocks.toastError).toHaveBeenCalledWith('Run terminal is unavailable.')
)
})
it('asks the server to open a run whose pane it slept, PTY id dropped', async () => {
mocks.state = storeState(false)
openRun({ ...run, terminalPtyId: null })
await vi.waitFor(() => expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalled())
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(WT)
expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: WT, tabId: HOST_TAB, environmentId: ENV, leafId: LEAF })
)
expect(mocks.toastMessage).not.toHaveBeenCalled()
})
})