Reduce idle remote runtime workspace work (#5895)

* Fix idle remote runtime session work

* fix: stamp remote/SSH worktrees with their repo execution host

Remote-runtime and SSH worktrees are fetched/created/reparented through the
owning host, which reports them from its own perspective (hostId defaults to
"local"). After the per-worktree hostId override in #2, that bogus "local"
overrode the repo's runtime owner, so terminals/sessions for a remote worktree
resolved to the local machine instead of the remote (clicking an omarchy-office
worktree opened a shell on the local omarchy-thinkpad).

Re-stamp every runtime worktree payload with the repo's execution host via a
shared withRepoHostId helper, applied at all worktreesByRepo ingress points:
toVisibleWorktrees (fetch), createWorktree, and applyWorktreeLineageUpdate.
Local-owned repos are left untouched, so an explicit local worktree still
overrides a runtime repo owner. Mirrors how repos already get repoWithFetchedOwner.

* fix: auto-discover remote runtime projects on connect

PR #2 gated the global session-tab sync to web clients only, removing the
desktop path that eagerly populated remote projects. With no on-connect repo
fetch left, remote projects only appeared after the user opened the
Add-Project dropdown (which calls fetchRuntimeEnvironmentRepos directly).

Seed an initial repo/worktree/lineage refresh for every runtime environment
that is already connected when useIpcEvents mounts, and for each one that
becomes connected afterward. Reuses the debounced/throttled refresh scheduler,
and works regardless of whether the remote server emits client events.

* fix: tighten idle runtime refresh behavior

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Damir Vandic 2026-06-22 22:44:25 +02:00 committed by GitHub
parent 3cc610bcc3
commit bbc5951958
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 821 additions and 64 deletions

View File

@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRuntimeProjectRefreshScheduler } from './runtime-project-refresh-scheduler'
describe('createRuntimeProjectRefreshScheduler', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0)
})
afterEach(() => {
vi.useRealTimers()
})
it('coalesces a burst of remote repo events into one refresh', async () => {
const refresh = vi.fn().mockResolvedValue(undefined)
const scheduler = createRuntimeProjectRefreshScheduler({
refresh,
debounceMs: 100,
minIntervalMs: 1_000
})
scheduler.request('env-1')
scheduler.request('env-1')
scheduler.request('env-1')
await vi.advanceTimersByTimeAsync(99)
expect(refresh).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(refresh).toHaveBeenCalledTimes(1)
expect(refresh).toHaveBeenCalledWith('env-1')
scheduler.stop()
})
it('throttles repeated bursts after the first refresh', async () => {
const refresh = vi.fn().mockResolvedValue(undefined)
const scheduler = createRuntimeProjectRefreshScheduler({
refresh,
debounceMs: 100,
minIntervalMs: 1_000
})
scheduler.request('env-1')
await vi.advanceTimersByTimeAsync(100)
expect(refresh).toHaveBeenCalledTimes(1)
scheduler.request('env-1')
scheduler.request('env-1')
await vi.advanceTimersByTimeAsync(999)
expect(refresh).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(refresh).toHaveBeenCalledTimes(2)
scheduler.stop()
})
it('waits for an in-flight refresh before running a pending follow-up', async () => {
let finishRefresh = (): void => {
throw new Error('Expected refresh promise resolver to be set')
}
const refresh = vi.fn(
() =>
new Promise<void>((resolve) => {
finishRefresh = resolve
})
)
const scheduler = createRuntimeProjectRefreshScheduler({
refresh,
debounceMs: 100,
minIntervalMs: 1_000
})
scheduler.request('env-1')
await vi.advanceTimersByTimeAsync(100)
expect(refresh).toHaveBeenCalledTimes(1)
scheduler.request('env-1')
await vi.advanceTimersByTimeAsync(2_000)
expect(refresh).toHaveBeenCalledTimes(1)
finishRefresh()
await Promise.resolve()
await vi.advanceTimersByTimeAsync(100)
expect(refresh).toHaveBeenCalledTimes(2)
scheduler.stop()
})
it('clears pending timers on stop', async () => {
const refresh = vi.fn().mockResolvedValue(undefined)
const scheduler = createRuntimeProjectRefreshScheduler({
refresh,
debounceMs: 100,
minIntervalMs: 1_000
})
scheduler.request('env-1')
scheduler.stop()
await vi.advanceTimersByTimeAsync(1_000)
expect(refresh).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,102 @@
export type RuntimeProjectRefreshSchedulerDeps = {
refresh: (environmentId: string) => Promise<void>
debounceMs?: number
minIntervalMs?: number
now?: () => number
onError?: (error: unknown) => void
}
export type RuntimeProjectRefreshScheduler = {
request: (environmentId: string) => void
stop: () => void
}
type RefreshEntry = {
inFlight: boolean
lastStartedAt: number
pending: boolean
timer: ReturnType<typeof setTimeout> | null
}
const DEFAULT_DEBOUNCE_MS = 250
const DEFAULT_MIN_INTERVAL_MS = 5_000
export function createRuntimeProjectRefreshScheduler(
deps: RuntimeProjectRefreshSchedulerDeps
): RuntimeProjectRefreshScheduler {
const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS
const minIntervalMs = deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
const now = deps.now ?? Date.now
const entries = new Map<string, RefreshEntry>()
let stopped = false
const getEntry = (environmentId: string): RefreshEntry => {
let entry = entries.get(environmentId)
if (!entry) {
entry = {
inFlight: false,
lastStartedAt: 0,
pending: false,
timer: null
}
entries.set(environmentId, entry)
}
return entry
}
const schedule = (environmentId: string, entry: RefreshEntry): void => {
if (stopped || entry.inFlight || entry.timer) {
return
}
const elapsed = entry.lastStartedAt > 0 ? now() - entry.lastStartedAt : minIntervalMs
const throttleDelay = Math.max(0, minIntervalMs - elapsed)
const delay = Math.max(debounceMs, throttleDelay)
entry.timer = setTimeout(() => {
entry.timer = null
void run(environmentId, entry)
}, delay)
}
const run = async (environmentId: string, entry: RefreshEntry): Promise<void> => {
if (stopped || !entry.pending) {
return
}
entry.pending = false
entry.inFlight = true
entry.lastStartedAt = now()
try {
await deps.refresh(environmentId)
} catch (error) {
deps.onError?.(error)
} finally {
entry.inFlight = false
if (entry.pending) {
// Why: runtime repo events can be noisy while a remote server is merely
// connected; keep discovery live without letting it drive the renderer.
schedule(environmentId, entry)
}
}
}
const request = (environmentId: string): void => {
const trimmedEnvironmentId = environmentId.trim()
if (!trimmedEnvironmentId || stopped) {
return
}
const entry = getEntry(trimmedEnvironmentId)
entry.pending = true
schedule(trimmedEnvironmentId, entry)
}
const stop = (): void => {
stopped = true
for (const entry of entries.values()) {
if (entry.timer) {
clearTimeout(entry.timer)
}
}
entries.clear()
}
return { request, stop }
}

View File

@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
buildRuntimeClientEventEnvironmentKey,
buildNewWorkspaceShortcutModalData,
getNewlyConnectedRuntimeEnvironmentIds,
getRuntimeProjectRefreshEnvironmentIds,
openNewWorkspaceFromShortcut,
resolveBrowserSessionTabTarget,
resolveZoomTarget
@ -37,6 +39,47 @@ describe('buildRuntimeClientEventEnvironmentKey', () => {
})
})
describe('getNewlyConnectedRuntimeEnvironmentIds', () => {
it('returns only environments that became connected', () => {
expect(getNewlyConnectedRuntimeEnvironmentIds(['env-a'], ['env-a', 'env-b'])).toEqual(['env-b'])
})
it('ignores environments that disconnected or stayed connected', () => {
expect(getNewlyConnectedRuntimeEnvironmentIds(['env-a', 'env-b'], ['env-a'])).toEqual([])
})
it('treats every environment as new when none were connected before', () => {
expect(getNewlyConnectedRuntimeEnvironmentIds([], ['env-a', 'env-a', 'env-b'])).toEqual([
'env-a',
'env-b'
])
})
})
describe('getRuntimeProjectRefreshEnvironmentIds', () => {
it('refreshes when an already-desired runtime becomes reachable', () => {
expect(
getRuntimeProjectRefreshEnvironmentIds({
previousDesired: ['env-a'],
nextDesired: ['env-a'],
previousReachable: [],
nextReachable: ['env-a']
})
).toEqual(['env-a'])
})
it('deduplicates runtimes that are both newly desired and newly reachable', () => {
expect(
getRuntimeProjectRefreshEnvironmentIds({
previousDesired: [],
nextDesired: ['env-a'],
previousReachable: [],
nextReachable: ['env-a']
})
).toEqual(['env-a'])
})
})
function expectWorktreeRouting(worktreeId: string): unknown {
return expect.objectContaining({ worktreeId })
}

View File

@ -81,6 +81,7 @@ import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close'
import { createWorktreeChangeRefreshQueue } from './worktree-change-refresh-queue'
import { subscribeRuntimeClientEvents } from '@/runtime/runtime-client-events'
import { createRuntimeProjectRefreshScheduler } from './runtime-project-refresh-scheduler'
import { createRuntimeClientEventsSync } from './runtime-client-events-sync'
import { detectLanguage } from '@/lib/language-detect'
import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
@ -129,6 +130,7 @@ function getShortcutPlatform(): NodeJS.Platform {
}
const BROWSER_AUTOMATION_BOOTSTRAP_LEASE_MS = 10_000
const RUNTIME_PROJECT_REFRESH_CONCURRENCY = 5
const browserAutomationBootstrapLeaseByPageId = new Map<string, { token: string; timer: number }>()
function isPinnedSessionTab(store: AppState, worktreeId: string, visibleId: string): boolean {
@ -725,12 +727,74 @@ function getRuntimeClientEventEnvironmentIds(): string[] {
return [...ids]
}
function getReachableRuntimeEnvironmentIds(): string[] {
const state = useAppStore.getState()
const ids: string[] = []
for (const [environmentId, status] of state.runtimeStatusByEnvironmentId ?? []) {
if (status?.status) {
ids.push(environmentId)
}
}
return ids
}
export function buildRuntimeClientEventEnvironmentKey(environmentIds: string[]): string {
return [...new Set(environmentIds)].sort().join('\u0000')
}
function getRuntimeClientEventEnvironmentKey(): string {
return buildRuntimeClientEventEnvironmentKey(getRuntimeClientEventEnvironmentIds())
/** Ids in `next` not in `previous` runtime environments that just became
* connected. Exported to unit-test the on-connect discovery trigger. */
export function getNewlyConnectedRuntimeEnvironmentIds(
previous: readonly string[],
next: readonly string[]
): string[] {
const known = new Set(previous)
return [...new Set(next)].filter((environmentId) => !known.has(environmentId))
}
export function getRuntimeProjectRefreshEnvironmentIds(args: {
previousDesired: readonly string[]
nextDesired: readonly string[]
previousReachable: readonly string[]
nextReachable: readonly string[]
}): string[] {
return [
...new Set([
...getNewlyConnectedRuntimeEnvironmentIds(args.previousDesired, args.nextDesired),
...getNewlyConnectedRuntimeEnvironmentIds(args.previousReachable, args.nextReachable)
])
]
}
async function refreshRuntimeProjectWorktrees(repos: readonly { id: string }[]): Promise<void> {
let nextIndex = 0
const failures: { repoId: string; error: unknown }[] = []
const workerCount = Math.min(RUNTIME_PROJECT_REFRESH_CONCURRENCY, repos.length)
// Why: one coalesced remote repo event can still represent many repos; keep the
// expensive worktree probes bounded so idle refresh never floods the renderer.
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < repos.length) {
const index = nextIndex
nextIndex += 1
const repoId = repos[index].id
try {
await useAppStore.getState().fetchWorktrees(repoId)
} catch (error) {
failures.push({ repoId, error })
}
}
})
)
if (failures.length > 0) {
throw new AggregateError(
failures.map((failure) => failure.error),
`Failed to refresh ${failures.length} runtime project worktree(s): ${failures
.map((failure) => failure.repoId)
.join(', ')}`
)
}
}
function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null {
@ -871,13 +935,20 @@ export function useIpcEvents(): void {
await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
}
const runtimeProjectRefreshScheduler = createRuntimeProjectRefreshScheduler({
refresh: async (environmentId) => {
const repos = await useAppStore.getState().fetchRuntimeEnvironmentRepos(environmentId)
await refreshRuntimeProjectWorktrees(repos)
await useAppStore.getState().fetchWorktreeLineage()
},
onError: (error) => {
console.error('Failed to refresh runtime projects:', error)
}
})
const handleRuntimeClientEvent = (environmentId: string, event: RuntimeClientEvent): void => {
if (event.type === 'reposChanged') {
const state = useAppStore.getState()
void state.fetchRuntimeEnvironmentRepos(environmentId).then(async (repos) => {
await Promise.all(repos.map((repo) => useAppStore.getState().fetchWorktrees(repo.id)))
await useAppStore.getState().fetchWorktreeLineage()
})
runtimeProjectRefreshScheduler.request(environmentId)
return
}
if (event.type === 'worktreesChanged') {
@ -909,18 +980,51 @@ export function useIpcEvents(): void {
})
runtimeClientEventsSync.sync()
let runtimeClientEventEnvironmentKey = getRuntimeClientEventEnvironmentKey()
// Why: PR #2 removed desktop's eager session-sync discovery and there is no
// on-connect repo fetch, so remote projects only appeared after the user
// opened the Add-Project dropdown. Seed discovery for runtimes already
// connected at mount, and for each newly-connected one below. The scheduler
// debounces/throttles, so this stays cheap even with chatty status updates.
let runtimeClientEventEnvironmentIds = getRuntimeClientEventEnvironmentIds()
for (const environmentId of runtimeClientEventEnvironmentIds) {
runtimeProjectRefreshScheduler.request(environmentId)
}
let runtimeClientEventEnvironmentKey = buildRuntimeClientEventEnvironmentKey(
runtimeClientEventEnvironmentIds
)
let reachableRuntimeEnvironmentIds = getReachableRuntimeEnvironmentIds()
let reachableRuntimeEnvironmentKey = buildRuntimeClientEventEnvironmentKey(
reachableRuntimeEnvironmentIds
)
unsubs.push(
useAppStore.subscribe(() => {
const nextKey = getRuntimeClientEventEnvironmentKey()
if (nextKey === runtimeClientEventEnvironmentKey) {
const nextEnvironmentIds = getRuntimeClientEventEnvironmentIds()
const nextKey = buildRuntimeClientEventEnvironmentKey(nextEnvironmentIds)
const nextReachableEnvironmentIds = getReachableRuntimeEnvironmentIds()
const nextReachableKey = buildRuntimeClientEventEnvironmentKey(nextReachableEnvironmentIds)
if (
nextKey === runtimeClientEventEnvironmentKey &&
nextReachableKey === reachableRuntimeEnvironmentKey
) {
return
}
for (const environmentId of getRuntimeProjectRefreshEnvironmentIds({
previousDesired: runtimeClientEventEnvironmentIds,
nextDesired: nextEnvironmentIds,
previousReachable: reachableRuntimeEnvironmentIds,
nextReachable: nextReachableEnvironmentIds
})) {
runtimeProjectRefreshScheduler.request(environmentId)
}
runtimeClientEventEnvironmentIds = nextEnvironmentIds
runtimeClientEventEnvironmentKey = nextKey
reachableRuntimeEnvironmentIds = nextReachableEnvironmentIds
reachableRuntimeEnvironmentKey = nextReachableKey
runtimeClientEventsSync.sync()
})
)
unsubs.push(runtimeClientEventsSync.stop)
unsubs.push(runtimeProjectRefreshScheduler.stop)
unsubs.push(
window.api.repos.onChanged(() => {

View File

@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
getExplicitRuntimeEnvironmentIdForWorktree,
getExecutionHostIdForWorktree,
getRuntimeEnvironmentIdForWorktree,
getSettingsForWorktreeRuntimeOwner,
type WorktreeRuntimeOwnerState
} from './worktree-runtime-owner'
@ -9,10 +11,12 @@ const state: WorktreeRuntimeOwnerState = {
settings: { activeRuntimeEnvironmentId: 'focused-env' },
repos: [
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
{ id: 'legacy-repo', connectionId: null, executionHostId: null },
{ id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:owner-env' }
],
worktreesByRepo: {
'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }],
'legacy-repo': [{ id: 'legacy-repo::wt-legacy', repoId: 'legacy-repo' }],
'runtime-repo': [{ id: 'runtime-repo::wt-b', repoId: 'runtime-repo' }]
},
projectGroups: [
@ -56,3 +60,60 @@ describe('getSettingsForWorktreeRuntimeOwner', () => {
expect(getExecutionHostIdForWorktree(state, 'folder:local-folder')).toBe('local')
})
})
describe('getExplicitRuntimeEnvironmentIdForWorktree', () => {
it('does not treat the focused runtime as ownership for legacy-local worktrees', () => {
expect(getRuntimeEnvironmentIdForWorktree(state, 'legacy-repo::wt-legacy')).toBe('focused-env')
expect(getExplicitRuntimeEnvironmentIdForWorktree(state, 'legacy-repo::wt-legacy')).toBeNull()
})
it('returns the runtime owner when the repo or folder explicitly names one', () => {
expect(getExplicitRuntimeEnvironmentIdForWorktree(state, 'runtime-repo::wt-b')).toBe(
'owner-env'
)
expect(getExplicitRuntimeEnvironmentIdForWorktree(state, 'folder:runtime-folder')).toBe(
'folder-env'
)
})
it('uses a worktree host id before the repo owner', () => {
const hostOverrideState: WorktreeRuntimeOwnerState = {
...state,
worktreesByRepo: {
...state.worktreesByRepo,
'runtime-repo': [
{ id: 'runtime-repo::wt-local-override', repoId: 'runtime-repo', hostId: 'local' },
{
id: 'runtime-repo::wt-runtime-override',
repoId: 'runtime-repo',
hostId: 'runtime:worktree-env'
}
]
}
}
expect(
getExplicitRuntimeEnvironmentIdForWorktree(
hostOverrideState,
'runtime-repo::wt-local-override'
)
).toBeNull()
expect(getRuntimeEnvironmentIdForWorktree(hostOverrideState, 'runtime-repo::wt-local-override'))
.toBeNull()
expect(getExecutionHostIdForWorktree(hostOverrideState, 'runtime-repo::wt-local-override')).toBe(
'local'
)
expect(
getExplicitRuntimeEnvironmentIdForWorktree(
hostOverrideState,
'runtime-repo::wt-runtime-override'
)
).toBe('worktree-env')
expect(
getRuntimeEnvironmentIdForWorktree(hostOverrideState, 'runtime-repo::wt-runtime-override')
).toBe('worktree-env')
expect(
getExecutionHostIdForWorktree(hostOverrideState, 'runtime-repo::wt-runtime-override')
).toBe('runtime:worktree-env')
})
})

View File

@ -13,19 +13,19 @@ import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
export type WorktreeRuntimeOwnerState = {
repos?: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreesByRepo?: Record<string, readonly Pick<Worktree, 'id' | 'repoId'>[]>
worktreesByRepo?: Record<string, readonly Pick<Worktree, 'id' | 'repoId' | 'hostId'>[]>
folderWorkspaces?: readonly Pick<FolderWorkspace, 'id' | 'projectGroupId'>[]
projectGroups?: readonly Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>[]
}
function findWorktreeRepoId(
function findWorktreeRecord(
worktreesByRepo: WorktreeRuntimeOwnerState['worktreesByRepo'],
worktreeId: string
): string | null {
): Pick<Worktree, 'id' | 'repoId' | 'hostId'> | null {
for (const worktrees of Object.values(worktreesByRepo ?? {})) {
const match = worktrees.find((worktree) => worktree.id === worktreeId)
if (match) {
return match.repoId
return match
}
}
return null
@ -59,6 +59,37 @@ function getRuntimeEnvironmentIdForFolderWorkspace(
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
}
function getExplicitRuntimeEnvironmentIdFromHost(
executionHostId: string | null | undefined
): string | null {
const parsed = parseExecutionHostId(executionHostId)
return parsed?.kind === 'runtime' ? parsed.environmentId : null
}
function getRuntimeEnvironmentIdFromWorktreeHost(
hostId: string | null | undefined
): string | null | undefined {
if (!hostId?.trim()) {
return undefined
}
return getExplicitRuntimeEnvironmentIdFromHost(hostId)
}
function getExecutionHostIdFromWorktreeHost(
hostId: string | null | undefined
): ExecutionHostId | null {
return parseExecutionHostId(hostId)?.id ?? null
}
function getExplicitRuntimeEnvironmentIdForFolderWorkspace(
state: WorktreeRuntimeOwnerState,
folderWorkspaceId: string
): string | null {
return getExplicitRuntimeEnvironmentIdFromHost(
findFolderProjectGroup(state, folderWorkspaceId)?.executionHostId
)
}
function getExecutionHostIdForFolderWorkspace(
state: WorktreeRuntimeOwnerState,
folderWorkspaceId: string
@ -86,8 +117,14 @@ export function getRuntimeEnvironmentIdForWorktree(
if (workspaceScope?.type === 'folder') {
return getRuntimeEnvironmentIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId)
}
const repoId =
findWorktreeRepoId(state.worktreesByRepo, worktreeId) ?? getRepoIdFromWorktreeId(worktreeId)
const worktree = findWorktreeRecord(state.worktreesByRepo, worktreeId)
const worktreeRuntimeEnvironmentId = getRuntimeEnvironmentIdFromWorktreeHost(worktree?.hostId)
if (worktreeRuntimeEnvironmentId !== undefined) {
// Why: the same repo can exist on local and remote hosts; a concrete
// worktree host must override the repo-level default owner.
return worktreeRuntimeEnvironmentId
}
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
const repo = state.repos?.find((entry) => entry.id === repoId)
const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim())
if (repo && hasExplicitOwner) {
@ -97,6 +134,34 @@ export function getRuntimeEnvironmentIdForWorktree(
return state.settings?.activeRuntimeEnvironmentId?.trim() || null
}
export function getExplicitRuntimeEnvironmentIdForWorktree(
state: WorktreeRuntimeOwnerState,
worktreeId: string | null | undefined
): string | null {
if (!worktreeId) {
return null
}
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
return getExplicitRuntimeEnvironmentIdForFolderWorkspace(
state,
workspaceScope.folderWorkspaceId
)
}
const worktree = findWorktreeRecord(state.worktreesByRepo, worktreeId)
if (worktree?.hostId) {
return getExplicitRuntimeEnvironmentIdFromHost(worktree.hostId)
}
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
const repo = state.repos?.find((entry) => entry.id === repoId)
if (!repo) {
return null
}
// Why: session mirroring is expensive; a merely focused runtime must not make
// legacy/local worktrees look remote-owned.
return getExplicitRuntimeEnvironmentIdFromHost(getRepoExecutionHostId(repo))
}
export function getExecutionHostIdForWorktree(
state: WorktreeRuntimeOwnerState,
worktreeId: string | null | undefined
@ -108,8 +173,14 @@ export function getExecutionHostIdForWorktree(
if (workspaceScope?.type === 'folder') {
return getExecutionHostIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId)
}
const repoId =
findWorktreeRepoId(state.worktreesByRepo, worktreeId) ?? getRepoIdFromWorktreeId(worktreeId)
const worktree = findWorktreeRecord(state.worktreesByRepo, worktreeId)
const worktreeHostId = getExecutionHostIdFromWorktreeHost(worktree?.hostId)
if (worktreeHostId) {
// Why: per-worktree host ownership is more specific than the repo host
// default, especially when local and runtime checkouts share a project.
return worktreeHostId
}
const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
const repo = state.repos?.find((entry) => entry.id === repoId)
const hasExplicitOwner = Boolean(repo?.executionHostId?.trim() || repo?.connectionId?.trim())
if (repo && hasExplicitOwner) {

View File

@ -23,6 +23,7 @@ import {
clearWebSessionTabsTrackingForEnvironment,
resolveHostSessionTabIdForWebSessionTab,
resetWebSessionTabsSnapshotFreshnessForTests,
shouldSyncAllRuntimeSessionTabs,
shouldApplyWebSessionTabsSnapshot,
shouldBootstrapInitialWebRuntimeTerminal,
shouldRespawnWebRuntimeTerminalAfterWake,
@ -268,29 +269,58 @@ describe('applyWebSessionTabsSnapshot', () => {
).toBe(true)
})
it('syncs session tabs for desktop remote runtime clients, not only web clients', () => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', false)
it('syncs active session tabs for desktop remote runtime clients using the worktree owner', () => {
expect(
shouldSyncRuntimeSessionTabs({
activeRuntimeEnvironmentId: ENV,
workspaceSessionReady: true
})
).toBe(true)
expect(
shouldSyncRuntimeSessionTabs({
activeRuntimeEnvironmentId: ENV,
activeWorktreeId: WT,
workspaceSessionReady: true,
requireActiveWorktree: true
activeWorktreeRuntimeEnvironmentId: ENV,
workspaceSessionReady: true
})
).toBe(true)
expect(
shouldSyncRuntimeSessionTabs({
activeRuntimeEnvironmentId: null,
activeWorktreeId: WT,
activeWorktreeRuntimeEnvironmentId: null,
workspaceSessionReady: true
})
).toBe(false)
expect(
shouldSyncRuntimeSessionTabs({
activeWorktreeId: WT,
activeWorktreeRuntimeEnvironmentId: 'other-env',
workspaceSessionReady: true
})
).toBe(true)
expect(
shouldSyncRuntimeSessionTabs({
activeWorktreeRuntimeEnvironmentId: ENV,
workspaceSessionReady: true
})
).toBe(false)
expect(
shouldSyncRuntimeSessionTabs({
activeWorktreeId: WT,
activeWorktreeRuntimeEnvironmentId: ENV,
workspaceSessionReady: false
})
).toBe(false)
})
it('only starts the all-session mirror for paired web clients', () => {
expect(
shouldSyncAllRuntimeSessionTabs({
activeRuntimeEnvironmentId: ENV,
workspaceSessionReady: true,
isWebClient: true
})
).toBe(true)
expect(
shouldSyncAllRuntimeSessionTabs({
activeRuntimeEnvironmentId: ENV,
workspaceSessionReady: true,
isWebClient: false
})
).toBe(false)
})
it('clears web session tracking maps when the host removes a worktree snapshot', () => {

View File

@ -32,6 +32,7 @@ import type { OpenFile } from '../store/slices/editor'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtime-terminal-stream'
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import {
createWebRuntimeSessionTerminal,
HOST_TERMINAL_SURFACE_SEPARATOR,
@ -240,16 +241,24 @@ export function shouldRespawnWebRuntimeTerminalAfterWake(args: {
}
export function shouldSyncRuntimeSessionTabs(args: {
activeRuntimeEnvironmentId: string | null | undefined
activeWorktreeId?: string | null
activeWorktreeRuntimeEnvironmentId?: string | null
workspaceSessionReady: boolean
requireActiveWorktree?: boolean
}): boolean {
const environmentId = args.activeRuntimeEnvironmentId?.trim()
const environmentId = args.activeWorktreeRuntimeEnvironmentId?.trim()
if (!environmentId || !args.workspaceSessionReady) {
return false
}
return args.requireActiveWorktree === true ? Boolean(args.activeWorktreeId) : true
return Boolean(args.activeWorktreeId?.trim())
}
export function shouldSyncAllRuntimeSessionTabs(args: {
activeRuntimeEnvironmentId: string | null | undefined
workspaceSessionReady: boolean
isWebClient: boolean
}): boolean {
const environmentId = args.activeRuntimeEnvironmentId?.trim()
return Boolean(environmentId && args.workspaceSessionReady && args.isWebClient)
}
export function resetWebSessionTabsSnapshotFreshnessForTests(): void {
@ -2322,16 +2331,23 @@ export function useWebSessionTabsSync(): void {
const activeRuntimeEnvironmentId = useAppStore(
(state) => state.settings?.activeRuntimeEnvironmentId ?? null
)
const activeWorktreeRuntimeEnvironmentId = useAppStore((state) =>
getExplicitRuntimeEnvironmentIdForWorktree(state, state.activeWorktreeId)
)
const workspaceSessionReady = useAppStore((state) => state.workspaceSessionReady)
const isWebClient = (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ === true
useEffect(() => {
const environmentId = activeRuntimeEnvironmentId?.trim()
// Why: startup hydration writes browser-local session state; applying the
// host snapshot before that point gets clobbered and leaves the sidebar stale.
// Desktop clients should not mirror every remote session just because a
// remote is connected; project discovery runs through separate repo APIs.
if (
!shouldSyncRuntimeSessionTabs({
!shouldSyncAllRuntimeSessionTabs({
activeRuntimeEnvironmentId,
workspaceSessionReady
workspaceSessionReady,
isWebClient
}) ||
!environmentId
) {
@ -2439,16 +2455,15 @@ export function useWebSessionTabsSync(): void {
// stale freshness/mapping entries should not live for the renderer lifetime.
clearWebSessionTabsTrackingForEnvironment(environmentId)
}
}, [activeRuntimeEnvironmentId, workspaceSessionReady])
}, [activeRuntimeEnvironmentId, isWebClient, workspaceSessionReady])
useEffect(() => {
const environmentId = activeRuntimeEnvironmentId?.trim()
const environmentId = activeWorktreeRuntimeEnvironmentId?.trim()
if (
!shouldSyncRuntimeSessionTabs({
activeRuntimeEnvironmentId,
activeWorktreeId,
workspaceSessionReady,
requireActiveWorktree: true
activeWorktreeRuntimeEnvironmentId,
workspaceSessionReady
}) ||
!environmentId ||
!activeWorktreeId
@ -2559,5 +2574,5 @@ export function useWebSessionTabsSync(): void {
disposed = true
unsubscribe?.()
}
}, [activeRuntimeEnvironmentId, activeWorktreeId, workspaceSessionReady])
}, [activeWorktreeId, activeWorktreeRuntimeEnvironmentId, workspaceSessionReady])
}

View File

@ -133,4 +133,23 @@ describe('Cmd+J lifted creation actions', () => {
})
expect(store.getState().tabsByWorktree['wt-1'] ?? []).toEqual([])
})
it('keeps desktop terminal creation local when a local worktree overrides a runtime repo owner', async () => {
delete pairedWebFlag.__ORCA_WEB_CLIENT__
createWebRuntimeSessionTerminalMock.mockResolvedValue(false)
const store = createTestStore()
seedActiveWorkspace(store)
store.setState({
repos: [{ ...TEST_REPO, executionHostId: 'runtime:owner-runtime' }],
worktreesByRepo: {
[TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id, hostId: 'local' })]
},
settings: { activeRuntimeEnvironmentId: null } as AppState['settings']
})
await store.getState().openNewTerminalTabInActiveWorkspace('group-1')
expect(createWebRuntimeSessionTerminalMock).not.toHaveBeenCalled()
expect(store.getState().tabsByWorktree['wt-1'] ?? []).toHaveLength(1)
})
})

View File

@ -1507,6 +1507,10 @@ describe('reconnectPersistedTerminals', () => {
// can pass them as sessionId to the daemon's createOrAttach.
expect(s.tabsByWorktree[wt1][0].ptyId).toBe('old-pty-1')
expect(s.tabsByWorktree[wt2][0].ptyId).toBe('old-pty-2')
expect(s.ptyIdsByTabId.tab1).toEqual(['old-pty-1'])
// Why: inactive worktrees keep a wake hint but must not advertise live PTYs
// until the user opens them and connectPanePty performs the actual reattach.
expect(s.ptyIdsByTabId.tab2).toEqual([])
expect(s.pendingReconnectWorktreeIds).toEqual([])
// No eager spawn — PTY creation deferred to pane mount
expect((mockApi.pty as Record<string, unknown>).spawn).not.toHaveBeenCalled()

View File

@ -255,6 +255,35 @@ describe('hydrateWorkspaceSession', () => {
expect(store.getState().worktreeNavHistoryIndex).toBe(0)
})
it('restores the active repo main worktree when the session has no active terminal tabs', () => {
const store = createTestStore()
const worktreeId = 'repo1::/wt-main'
seedStore(store, {
worktreesByRepo: {
repo1: [
makeWorktree({
id: worktreeId,
repoId: 'repo1',
path: '/wt-main',
isMainWorktree: true
})
]
}
})
store.getState().hydrateWorkspaceSession({
activeRepoId: 'repo1',
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
})
expect(store.getState().activeWorktreeId).toBe(worktreeId)
expect(store.getState().activeWorkspaceKey).toBe(`worktree:${worktreeId}`)
expect(store.getState().worktreeNavHistory).toEqual([worktreeId])
})
it('leaves nav history empty when no active worktree is restored', () => {
const store = createTestStore()
seedStore(store, { worktreesByRepo: {} })

View File

@ -41,7 +41,6 @@ import {
updateGroup
} from './tab-group-state'
import {
ensurePtyDispatcher,
restorePtyDataHandlersAfterFailedShutdown,
unregisterPtyDataHandlers
} from '@/components/terminal-pane/pty-transport'
@ -2561,10 +2560,22 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
validWorktreeIds.has(record.worktreeId)
)
)
const activeWorktreeId =
session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)
? session.activeWorktreeId
const fallbackActiveWorktreeId =
!session.activeWorktreeId && session.activeRepoId && knownRepoIds.has(session.activeRepoId)
? (s.worktreesByRepo[session.activeRepoId]?.find((worktree) => worktree.isMainWorktree)
?.id ??
s.worktreesByRepo[session.activeRepoId]?.[0]?.id ??
null)
: null
const activeWorktreeId = (() => {
if (session.activeWorktreeId && validWorktreeIds.has(session.activeWorktreeId)) {
return session.activeWorktreeId
}
// Why: a workspace with no terminal tabs is still a valid workspace.
// Falling back from the active repo prevents the blank landing screen
// when session tabs were pruned or never created.
return fallbackActiveWorktreeId
})()
const activeWorkspaceKey: WorkspaceKey | null =
session.activeWorkspaceKey && validWorktreeIds.has(session.activeWorkspaceKey)
? session.activeWorkspaceKey
@ -2783,7 +2794,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
pendingReconnectTabByWorktree,
pendingReconnectPtyIdByTabId,
terminalLayoutsByTabId,
tabsByWorktree
tabsByWorktree,
activeWorktreeId
} = get()
const ids = pendingReconnectWorktreeIds ?? []
@ -2807,8 +2819,6 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// The layout's ptyIdsByLeafId (preserved from shutdown) already has per-leaf
// mappings. For single-pane tabs without leaf mappings, store the tab-level
// ptyId as a sentinel so connectPanePty knows to reattach.
ensurePtyDispatcher()
for (const worktreeId of ids) {
const tabs = tabsByWorktree[worktreeId] ?? []
const worktree = Object.values(get().worktreesByRepo)
@ -2859,6 +2869,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
`[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}`
)
if (tabLevelPtyId) {
const shouldAdvertiseLivePtys = worktreeId === activeWorktreeId
set((s) => {
const next = { ...s.tabsByWorktree }
if (!next[worktreeId]) {
@ -2877,10 +2888,17 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
: [tabLevelPtyId]
return {
tabsByWorktree: next,
ptyIdsByTabId: {
...s.ptyIdsByTabId,
[tabId]: allPtyIds
}
...(shouldAdvertiseLivePtys
? {
// Why: inactive worktrees only need wake hints. Publishing
// their PTYs as live at startup starts global session/status
// machinery before the user opens that workspace.
ptyIdsByTabId: {
...s.ptyIdsByTabId,
[tabId]: allPtyIds
}
}
: {})
}
})
}

View File

@ -754,7 +754,48 @@ describe('fetchWorktrees', () => {
expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'repo-ssh' })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([sshWorktree])
// Why: SSH worktrees are fetched via local IPC but belong to the SSH host;
// they must carry the repo's ssh host id, not the local default.
expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([
{ ...sshWorktree, hostId: 'ssh:ssh-1' }
])
})
it('stamps remote runtime worktrees with the owning repo runtime host', async () => {
const store = createTestStore()
// Why: a remote runtime returns its worktrees from its own perspective, so
// their hostId arrives as the default "local" even though they live remotely.
const remote = makeWorktree({
id: 'repo-remote::/remote/wt1',
repoId: 'repo-remote',
path: '/remote/wt1',
branch: 'refs/heads/remote',
hostId: 'local'
})
store.setState({
repos: [
{
id: 'repo-remote',
path: '/home/dvic/src/omarchy-dotfiles',
displayName: 'omarchy-dotfiles',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'runtime:env-1'
}
]
} as Partial<AppState>)
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: makeDetectedResult('repo-remote', [remote]),
_meta: { runtimeId: 'runtime-remote' }
})
await store.getState().fetchWorktrees('repo-remote')
expect(store.getState().worktreesByRepo['repo-remote']).toEqual([
{ ...remote, hostId: 'runtime:env-1' }
])
})
it('falls back to legacy remote worktree.list when detectedList is unavailable', async () => {
@ -1166,6 +1207,50 @@ describe('worktree lineage state', () => {
expect(store.getState().sortEpoch).toBe(4)
})
it('stamps the owning runtime host onto worktrees returned by a remote lineage update', async () => {
const store = createTestStore()
const lineage = makeLineage({
worktreeId: 'repo-remote::/remote/child',
parentWorktreeId: 'repo-remote::/remote/parent'
})
const child = makeWorktree({
id: lineage.worktreeId,
repoId: 'repo-remote',
path: '/remote/child'
})
// Why: the remote returns the updated worktree from its own perspective, so
// it arrives with the default local host even though the repo is remote.
const updatedChild = { ...child, hostId: 'local' as const, lineage }
store.setState({
repos: [
{
id: 'repo-remote',
path: '/home/dvic/src/omarchy-dotfiles',
displayName: 'omarchy-dotfiles',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'runtime:env-1'
}
],
worktreesByRepo: { 'repo-remote': [child] }
} as Partial<AppState>)
runtimeEnvironmentCall.mockResolvedValueOnce({
id: 'rpc-remote-lineage',
ok: true,
result: { worktree: updatedChild },
_meta: { runtimeId: 'runtime-remote' }
})
await store.getState().updateWorktreeLineage(lineage.worktreeId, {
parentWorktreeId: lineage.parentWorktreeId
})
expect(store.getState().worktreesByRepo['repo-remote']?.[0]).toEqual({
...updatedChild,
hostId: 'runtime:env-1'
})
})
it('assigns a parent through the active remote runtime environment and rethrows failures', async () => {
const store = createTestStore()
const lineage = makeLineage()
@ -1639,6 +1724,46 @@ describe('createWorktree base status merge', () => {
})
})
it('stamps the owning runtime host onto worktrees created on a remote runtime', async () => {
const store = createTestStore()
const created = makeWorktree({
id: 'repo-remote::/remote/feature',
repoId: 'repo-remote',
path: '/remote/feature',
// Why: the remote creates the worktree and reports it from its own
// perspective, so it comes back with the default local host.
hostId: 'local'
})
store.setState({
repos: [
{
id: 'repo-remote',
path: '/home/dvic/src/omarchy-dotfiles',
displayName: 'omarchy-dotfiles',
badgeColor: '#000',
addedAt: 0,
executionHostId: 'runtime:env-1'
}
]
} as Partial<AppState>)
runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) =>
Promise.resolve({
id: 'rpc-remote-create',
ok: true,
result: method === 'worktree.create' ? { worktree: created } : null,
_meta: { runtimeId: 'runtime-remote' }
})
)
await store.getState().createWorktree('repo-remote', 'feature', 'origin/main')
expect(mockApi.worktrees.create).not.toHaveBeenCalled()
expect(store.getState().worktreesByRepo['repo-remote']?.[0]).toEqual({
...created,
hostId: 'runtime:env-1'
})
})
it('passes the active folder workspace as parent for in-app worktree creates', async () => {
const store = createTestStore()
const wt = makeWorktree({

View File

@ -45,6 +45,7 @@ import { translate } from '@/i18n/i18n'
import {
getRepoExecutionHostId,
getSettingsFocusedExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
@ -312,8 +313,31 @@ function toVisibleWorktree(worktree: DetectedWorktreeListResult['worktrees'][num
return base
}
function toVisibleWorktrees(result: DetectedWorktreeListResult): Worktree[] {
return result.worktrees.filter((worktree) => worktree.visible).map(toVisibleWorktree)
// Why: runtime worktree payloads arrive from the owning host's own perspective,
// so their hostId defaults to "local" even for remote checkouts. Re-stamp them
// with the repo's execution host so per-worktree host resolution doesn't route
// remote terminals to the local machine. Local-owned repos are left untouched,
// so an explicit local worktree still overrides a runtime repo owner.
function withRepoHostId<T extends { hostId?: ExecutionHostId }>(
worktree: T,
hostId: ExecutionHostId
): T {
return hostId === LOCAL_EXECUTION_HOST_ID ? worktree : { ...worktree, hostId }
}
function repoHostId(state: Pick<AppState, 'repos'>, repoId: string): ExecutionHostId {
const repo = state.repos.find((entry) => entry.id === repoId)
return repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID
}
function toVisibleWorktrees(
result: DetectedWorktreeListResult,
hostId: ExecutionHostId
): Worktree[] {
return result.worktrees
.filter((worktree) => worktree.visible)
.map(toVisibleWorktree)
.map((worktree) => withRepoHostId(worktree, hostId))
}
function getHydratedSessionWorktreeIdsForRepo(state: AppState, repoId: string): string[] {
@ -781,7 +805,13 @@ function applyWorktreeLineageUpdate(
worktreesByRepo:
result.target.kind === 'local' || !result.updatedRemoteWorktree
? s.worktreesByRepo
: replaceWorktreeInRepoLists(s.worktreesByRepo, result.updatedRemoteWorktree),
: replaceWorktreeInRepoLists(
s.worktreesByRepo,
withRepoHostId(
result.updatedRemoteWorktree,
repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id))
)
),
sortEpoch: s.sortEpoch + 1
}
})
@ -1526,7 +1556,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
if (options?.requireAuthoritative && !detected.authoritative) {
return false
}
const worktrees = toVisibleWorktrees(detected)
const worktrees = toVisibleWorktrees(detected, repoHostId(get(), repoId))
const current = get().worktreesByRepo[repoId]
if (areWorktreesEqual(current, worktrees)) {
set((s) => {
@ -1619,7 +1649,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
settingsForRepoOwner(get(), r.id),
r.id
)
const list = toVisibleWorktrees(detected)
const list = toVisibleWorktrees(detected, repoHostId(get(), r.id))
const current = get().worktreesByRepo[r.id]
if (
!areWorktreesEqual(current, list) &&
@ -1957,15 +1987,16 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
// then produces a duplicate entry in worktreesByRepo, which gives
// React duplicate keys and can corrupt terminal DOM containers.
set((s) => {
const createdWorktree = withRepoHostId(result.worktree, repoHostId(s, repoId))
const current = s.worktreesByRepo[repoId] ?? []
const alreadyPresent = current.some((w) => w.id === result.worktree.id)
const alreadyPresent = current.some((w) => w.id === createdWorktree.id)
const nextWorktrees = alreadyPresent
? current.map((worktree) =>
worktree.id === result.worktree.id
? { ...worktree, ...result.worktree }
worktree.id === createdWorktree.id
? { ...worktree, ...createdWorktree }
: worktree
)
: [...current, result.worktree]
: [...current, createdWorktree]
return {
worktreesByRepo: {
...s.worktreesByRepo,