Files
orca/src/main/window/runtime-window-lifecycle.ts
T
Jinwoo HongandClaude 41661e3312 feat(sidebar): main runs workspace sleep; the window follows (P3b) (#31564)
* fix(sidebar): land flip-a's switch-on fixes on main, still dark (no flip)

The fixes found while the sidebar ran from main's feed, without turning
that on: ORCA_SIDEBAR_INPUTS_IS_MAIN stays false, and none of flip-a's
stop-gaps come along.

- Main stops counting a closed tab's terminal once it was asked to stop
  and no pane shows it; a failed or refused stop takes the request back,
  and a host out of contact stays unverifiable.
- Main fetches a paired server's rows with the transport's normal bound,
  so a server slower than 5 s still lists.
- A deferred unread raise flips the folder row it was decided for, and a
  read while the owner check is out cancels it.
- Phone: no worktree.ps flash before the feed; a host's last installed
  feed stands in while a fresh read is out; it highlights its own
  last-opened workspace and a fold never scrolls; projects file under
  their project group with the desktop's indent; the collapse spinner
  shows only after 500 ms; "Reopen to update" keeps the rows on screen.
- e2e helpers and journeys for the switch-on suite.

* fix(sidebar): keep an owed relay stop marked, clear a thrown stop, and prune pending unread raises

- A stop-and-wait that throws takes its stop request back, like one that fails.
- A final stop the legacy relay still owes stays marked, since the relay retries it.
- Pending unread raises are tracked only while one waits, so the map no longer grows.
- The paired-server fallback poll test gives its third fetch a reply and asserts live.

* feat(sidebar): main publishes cached snapshots and announces its own stores; detected, view and live-content domains (inert)

* fix(sidebar): keep links confirmed only by a new git run, no rescan on metadata writes, no phone wait on detected checkouts

* fix(sidebar): rescan the workspace rows when the Windows runtime default changes

* feat(sidebar): one lifecycle queue per workspace in main, with in-progress hints (switch-gated)

Create, delete, terminal.closeAll, terminal.sleep and worktree.sleep now run through one
first-in, first-out queue per host-qualified workspace: strict order, no merging, delete
cancels what has not started and waits for what has, an unconfirmed outcome pauses until
the host's own answer. Hints publish in a new inProgress feed domain (no reader yet); the
creations domain is its projection. A finished create or delete keeps its hint until a scan
started after it applies, in the same revision. Adds the paired-server metadata serializer
(saving domain, no caller yet). Replaces the creation registry.

* fix(sidebar): a create never queues behind the previous occupant's delete

* feat(sidebar): main runs sleep; the window follows (P3b)

* fix(sidebar): keep electron out of the headless server's sleep path

The desktop registers how sleep reaches paired servers; a headless serve has none.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(sidebar): type-check the sleep e2e specs; drop the removed VM suspend stub

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sidebar): resume a paused workspace by re-observing, never replaying

- A command issued while paused waits behind the pause; when the owner answers again main
  re-reads terminal liveness and the listing, then carries on. A forced delete runs past it.
- Commands keep their arrival order across a workspace lookup; deletes by name key by the
  resolved id, so they wait for that workspace's running sleep.
- A second delete with different options is refused as already in progress.
- Close and delete read lost contact as sleep does.
- A create always frees its own queue entry, even after a retry took over its id.
- Drops the unused opId, and the paired-server write serializer and its feed section,
  which land with their first caller.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sidebar): main is the only writer of a sleep's wake records; sleep classifies only lost contact as unconfirmed

- The window no longer captures wake records for a sleep main runs; it takes main's records
  from main's answer, which its wake and tab-open resume then read.
- A workspace reopened during its sleep keeps its VM running (the window and a phone's
  activation tell main).
- Only lost contact pauses: a definite chat-stop failure or a server's still-live answer is a
  plain failure with today's toast.
- A paired server's chats are asked with the Sleep mark, and again without it when an older
  server refuses the field, instead of reading an unprobed status.
- A phone's worktree.sleep is answered on acceptance and its activation no longer waits on a
  queued wake, so neither can outlast the phone's request timeout. The web client passes
  markChatsAsleep and keeps the receipts.
- One desktop adapter, passed where the desktop builds the runtime, replaces the two module
  slots; a headless server gets none.

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(sidebar): share worktree.sleep's params with the RPC catalog; lint fixes

Co-Authored-By: Claude <noreply@anthropic.com>

* test(sidebar): test the paired-server sleep where main runs it; drop the window's copies

The window no longer sleeps a paired server's terminals, so its convergence and order tests for
that path move to main (remote-workspace-terminal-sleep.test.ts) or follow main's answer.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(sidebar): retry the sleep spec's liveness read while terminals stop

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(sleep): main resumes every workspace-sleep wake; the window only attaches

- Main claims and starts each slept pane through its windowless resume path, with or
  without a window: the "window present" check is gone.
- A phone's open queues main's wake, which finishes only once the resumes have started,
  and always notes the reopen so a running sleep leaves the VM up.
- The window no longer relaunches a workspace sleep's records in fresh tabs; opening a
  slept pane asks main to resume it and the pane's spawn attaches to main's terminal.
- A window save cannot overwrite main's workspace-sleep records.
- A VM that would not suspend no longer reads as "kept open": the terminals slept, and
  the toast says the machine is still running.

* test(e2e): main resumes a slept pane once, in place, and keeps its record

* chore(i18n): add the VM-still-running sleep message

* fix(sleep): a phone's or the CLI's failed sleep shows the desktop toast again

* fix: restore main's final P2 test fixes a merge reverted

* fix(sleep): window saves never write or clear main's sleep records; headless wake report counts only real sleeps

* test: a same-id twin's grace follows the rows' change, not the removed catalog hook

* fix(sleep): drop the 1 s settle wait after an observed exit, and wake a slept workspace's mounted agents on phone open

A workspace sleep stopped each terminal through main's stop-and-verify, which polled for a
further second after the exit it had already seen. And a phone opening a slept workspace no
longer reached the window's mounted panes of agents that had finished before the sleep, so
they stayed asleep until the window opened their tab.

* test: give the workspace-sleep wake record its prompt field

* refactor: match slept-agent records to the workspace with worktreeIdsEqual

* chore(sleep): one import from sleep-record-pane-claim

* test(sleep): route the disconnected-sleep SSH spec, declare the desktop sleep adapter, and open the workspace after its sleep starts

* test(sleep): delete a workspace the spec creates, not the seeded one later specs share

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-22 19:39:07 -04:00

258 lines
12 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { ipcMain } from 'electron'
import type { BrowserWindow } from 'electron'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types'
import type { RuntimeMobileSessionTabMove } from '../../shared/runtime-types'
import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity'
import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators'
import { requestAgentLaunchTabPublishFromRenderer } from './agent-launch-tab-publish-relay'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { registerRendererDocumentNavigation } from './renderer-document-navigation'
import { createRuntimeRendererNotificationSender } from './runtime-renderer-notification-sender'
import { requestSessionTabCloseFromRenderer } from './session-tab-close-request-relay'
import { revealTerminalPaneInWindow } from './terminal-pane-reveal-relay'
import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay'
import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../shared/sidebar-inputs-switch'
let runtimeNotifierTokenCounter = 0
let activeRuntimeNotifierToken: number | null = null
export function registerRuntimeWindowLifecycle(
mainWindow: BrowserWindow,
runtime: OrcaRuntimeService
): void {
const notifierToken = ++runtimeNotifierTokenCounter
activeRuntimeNotifierToken = notifierToken
runtime.attachWindow(mainWindow.id)
const mainWebContents = mainWindow.webContents
const rendererNotifications = createRuntimeRendererNotificationSender({
isWindowDestroyed: () => mainWindow.isDestroyed(),
webContents: mainWebContents,
onFailure: (reason) => runtime.markGraphReloadFailed(mainWindow.id, reason)
})
const send = rendererNotifications.send
runtime.setNotifier({
worktreesChanged: (repoId, renamed, options) => {
// Why: clear scan caches before the renderer handles this event, so it can't read stale TTL entries after a mutation.
if (options?.invalidateGitScans !== false) {
runWorktreeChangeInvalidators(repoId)
}
send('worktrees:changed', renamed ? { repoId, renamed } : { repoId })
},
worktreeBaseStatus: (event) => send('worktree:baseStatus', event),
worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event),
reposChanged: () => send('repos:changed'),
automationsChanged: (payload) => send('automations:changed', payload),
activateWorktree: (
repoId,
worktreeId,
setup?: CreateWorktreeResult['setup'],
startup?: WorktreeStartupLaunch,
defaultTabs?: CreateWorktreeResult['defaultTabs'],
providesInitialSurface?: boolean
) => {
send('ui:activateWorktree', {
repoId,
worktreeId,
...(setup ? { setup } : {}),
...(startup ? { startup } : {}),
...(defaultTabs ? { defaultTabs } : {}),
...(providesInitialSurface ? { providesInitialSurface } : {})
})
},
createTerminal: (worktreeId, opts) =>
send('ui:createTerminal', {
worktreeId,
command: opts.command,
...(opts.cwd ? { cwd: opts.cwd } : {}),
...(opts.env ? { env: opts.env } : {}),
title: opts.title,
...(opts.presentation ? { presentation: opts.presentation } : {})
}),
revealTerminalSession: (worktreeId, opts) =>
new Promise((resolve, reject) => {
const requestId = randomUUID()
const expectedIdentity = opts.expectedProcessIdentity
? opts.tabId && opts.leafId
? { worktreeId, tabId: opts.tabId, leafId: opts.leafId, ptyId: opts.ptyId }
: null
: undefined
if (expectedIdentity === null) {
reject(new Error('terminal_reveal_identity_required'))
return
}
const timer = setTimeout(() => {
ipcMain.removeListener('terminal:tabCreateReply', handler)
reject(new Error('Terminal reveal timed out'))
}, 10_000)
const handler = (event: Electron.IpcMainEvent, reply: TerminalTabCreateReply): void => {
// Why: requestId is renderer-supplied, so only the targeted main window may satisfy the reveal.
if (event.sender !== mainWindow.webContents || reply.requestId !== requestId) {
return
}
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
if (reply.error) {
reject(new Error(reply.error))
return
}
if (
expectedIdentity &&
(!reply.identity ||
reply.identity.worktreeId !== expectedIdentity.worktreeId ||
reply.identity.tabId !== expectedIdentity.tabId ||
reply.identity.leafId !== expectedIdentity.leafId ||
reply.identity.ptyId !== expectedIdentity.ptyId)
) {
reject(new Error('terminal_reveal_identity_mismatch'))
return
}
resolve({
tabId: reply.tabId!,
title: reply.title,
...(reply.identity ? { identity: reply.identity } : {})
})
}
ipcMain.on('terminal:tabCreateReply', handler)
const sent = send('ui:createTerminal', {
requestId,
worktreeId,
ptyId: opts.ptyId,
title: opts.title ?? undefined,
...(opts.cwd ? { cwd: opts.cwd } : {}),
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
...(opts.launchToken ? { launchToken: opts.launchToken } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
...(opts.targetGroupId ? { targetGroupId: opts.targetGroupId } : {}),
...(opts.afterTabId ? { afterTabId: opts.afterTabId } : {}),
...(opts.shellOverride ? { shellOverride: opts.shellOverride } : {}),
activate: opts.activate !== false,
...(opts.presentation ? { presentation: opts.presentation } : {}),
...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}),
...(opts.agentLaunchViewer ? { agentLaunchViewer: opts.agentLaunchViewer } : {}),
// Why: pre-minted tabId aligns the renderer tab id with the paneKey baked into the PTY env, so hook events route right.
...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}),
...(opts.leafId !== undefined ? { leafId: opts.leafId } : {}),
...(opts.splitFromLeafId !== undefined ? { splitFromLeafId: opts.splitFromLeafId } : {}),
...(opts.splitDirection !== undefined ? { splitDirection: opts.splitDirection } : {}),
...(opts.splitTelemetrySource !== undefined
? { splitTelemetrySource: opts.splitTelemetrySource }
: {}),
...(opts.focus !== undefined ? { focus: opts.focus } : {})
})
if (!sent) {
clearTimeout(timer)
ipcMain.removeListener('terminal:tabCreateReply', handler)
reject(new Error('runtime_unavailable'))
}
}),
publishAgentLaunchTab: (request) =>
requestAgentLaunchTabPublishFromRenderer(mainWindow, request),
agentLaunchPaneVerdict: (event) => send('ui:agentLaunchPaneVerdict', event),
resolveLegacyWorkerTerminalRecovery: (paneKey, resolution, ptyId) =>
send('agentStatus:legacyWorkerTerminalRecovery', {
paneKey,
resolution,
...(ptyId ? { ptyId } : {})
}),
splitTerminal: (tabId, paneRuntimeId, opts) => {
send('ui:splitTerminal', {
tabId,
paneRuntimeId,
direction: opts.direction,
command: opts.command,
worktreeId: opts.worktreeId,
sourceLeafId: opts.sourceLeafId,
telemetrySource: opts.telemetrySource,
newLeafId: opts.newLeafId
})
},
renameTerminal: (tabId, title, options) =>
send('ui:renameTerminal', { tabId, title, ...options }),
setPaneTitle: (tabId, leafId, title) => send('ui:setPaneTitle', { tabId, leafId, title }),
focusTerminal: (tabId, worktreeId, leafId) =>
send('ui:focusTerminal', { tabId, worktreeId, leafId }),
revealTerminalPane: (target) => revealTerminalPaneInWindow(mainWindow, target),
focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }),
closeSessionTab: (tabId, worktreeId) =>
requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId),
moveSessionTab: (worktreeId: string, move: RuntimeMobileSessionTabMove) =>
send('ui:moveSessionTab', { worktreeId, ...move }),
openFile: (worktreeId, filePath, relativePath, runtimeEnvironmentId?, navigation?, position?) =>
send('ui:openFileFromMobile', {
worktreeId,
filePath,
relativePath,
runtimeEnvironmentId,
...(navigation ? { navigation } : {}),
...(position ? { position } : {})
}),
revealEditorTab: (reveal) => send('ui:revealEditorTabFromRuntime', reveal),
openDiff: (worktreeId, filePath, relativePath, staged, runtimeEnvironmentId?, navigation?) =>
send('ui:openDiffFromMobile', {
worktreeId,
filePath,
relativePath,
staged,
runtimeEnvironmentId,
...(navigation ? { navigation } : {})
}),
closeTerminal: (tabId) => send('ui:closeTerminal', { kind: 'tab', tabId }),
closeTerminalPane: (tabId, leafId) => send('ui:closeTerminal', { kind: 'pane', tabId, leafId }),
closeTerminalTab: (tabId, options) =>
requestTerminalTabCloseFromRenderer(mainWindow, tabId, options),
workspaceSleepChanged: (notice) => send('ui:workspaceSleep', notice),
resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }),
terminalFitOverrideChanged: (ptyId, mode, cols, rows) =>
send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }),
terminalDriverChanged: (ptyId, driver) =>
send('runtime:terminalDriverChanged', { ptyId, driver }),
nativeChatLaunchDraftResolved: (tabId, resolution) =>
send('runtime:nativeChatLaunchDraftResolved', { tabId, ...resolution }),
browserDriverChanged: (browserPageId, driver) =>
send('runtime:browserDriverChanged', { browserPageId, driver }),
browserRemoteViewersChanged: (browserPageId, hasRemoteViewers) =>
send('runtime:browserRemoteViewersChanged', { browserPageId, hasRemoteViewers }),
clientHostedBrowserRowsChanged: (event) => send('runtime:clientHostedBrowserRowsChanged', event)
})
// Switch point: the window reads its sidebar from main's feed and re-reads on each notice.
const detachSidebarInputsNotices = ORCA_SIDEBAR_INPUTS_IS_MAIN
? runtime.onClientEvent(
(event) => {
if (event.type === 'sidebarInputsChanged') {
send('runtime:sidebarInputsChanged', event)
}
},
{ consumesTerminalSideEffects: false }
)
: null
registerRendererDocumentNavigation(mainWebContents, () => {
rendererNotifications.onMainFrameReloadStarted()
const fence = runtime.markRendererReloading(mainWindow.id)
return () => {
if (fence && runtime.markRendererReloadCancelled(mainWindow.id, fence)) {
rendererNotifications.onMainFrameReloadCancelled()
}
}
})
mainWebContents.on('did-finish-load', () => {
rendererNotifications.onMainFrameLoadFinished()
})
mainWebContents.on('render-process-gone', () => {
rendererNotifications.onRendererProcessGone()
})
mainWindow.on('closed', () => {
detachSidebarInputsNotices?.()
rendererNotifications.close()
runtime.markGraphUnavailable(mainWindow.id)
if (activeRuntimeNotifierToken === notifierToken) {
// Why: the notifier closes over the window; clear it in the no-window gap so the runtime can't retain destroyed graphs.
runtime.setNotifier(null)
activeRuntimeNotifierToken = null
}
})
}