mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(worktrees): stop terminals after external deletion
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation'
|
||||
|
||||
function createProvider(sessionIds: string[]): IPtyProvider {
|
||||
return {
|
||||
listProcesses: vi.fn(async () =>
|
||||
sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' }))
|
||||
),
|
||||
shutdown: vi.fn(async () => {})
|
||||
} as unknown as IPtyProvider
|
||||
}
|
||||
|
||||
function createRuntime(): OrcaRuntimeService {
|
||||
return {
|
||||
stopTerminalsForWorktree: vi.fn(async () => ({ stopped: 0 }))
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
const localRepo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 1
|
||||
}
|
||||
|
||||
describe('stopMissingWorktreeTerminals', () => {
|
||||
it('stops only locally owned worktrees absent from the authoritative scan', async () => {
|
||||
const deletedId = 'repo-1::/workspace/deleted'
|
||||
const survivingId = 'repo-1::/workspace/surviving'
|
||||
const provider = createProvider([
|
||||
`${deletedId}@@deleted-session`,
|
||||
`${survivingId}@@surviving-session`
|
||||
])
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(
|
||||
localRepo,
|
||||
[deletedId, survivingId, 'repo-2::/workspace/other'],
|
||||
[survivingId],
|
||||
{
|
||||
runtime,
|
||||
getLocalProvider: () => provider,
|
||||
getSshProvider: () => undefined
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(provider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@deleted-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(provider.shutdown).not.toHaveBeenCalledWith(
|
||||
`${survivingId}@@surviving-session`,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the owning SSH provider without consulting the local provider', async () => {
|
||||
const deletedId = 'repo-1::/workspace/deleted'
|
||||
const localProvider = createProvider([`${deletedId}@@local-session`])
|
||||
const sshProvider = createProvider([`${deletedId}@@ssh-session`])
|
||||
const getSshProvider = vi.fn(() => sshProvider)
|
||||
|
||||
await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, [deletedId], [], {
|
||||
runtime: createRuntime(),
|
||||
getLocalProvider: () => localProvider,
|
||||
getSshProvider
|
||||
})
|
||||
|
||||
expect(getSshProvider).toHaveBeenCalledWith('ssh-1')
|
||||
expect(sshProvider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@ssh-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(localProvider.listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still stops graph-visible sessions when the owning provider is unavailable', async () => {
|
||||
const deletedId = 'repo-1::/workspace/deleted'
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(
|
||||
{ ...localRepo, connectionId: 'ssh-1' },
|
||||
[deletedId],
|
||||
[],
|
||||
{
|
||||
runtime,
|
||||
getLocalProvider: () => null,
|
||||
getSshProvider: () => undefined
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith(deletedId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { IPtyProvider } from '../providers/types'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import { killAllProcessesForWorktree } from './worktree-teardown'
|
||||
|
||||
const MISSING_WORKTREE_TEARDOWN_CONCURRENCY = 4
|
||||
|
||||
type MissingWorktreeTerminalReconciliationDeps = {
|
||||
runtime: OrcaRuntimeService
|
||||
getLocalProvider: () => IPtyProvider | null
|
||||
getSshProvider: (connectionId: string) => IPtyProvider | undefined
|
||||
onPtyStopped?: (ptyId: string) => void
|
||||
}
|
||||
|
||||
export async function stopMissingWorktreeTerminals(
|
||||
repo: Repo,
|
||||
knownWorktreeIds: readonly string[],
|
||||
detectedWorktreeIds: readonly string[],
|
||||
deps: MissingWorktreeTerminalReconciliationDeps
|
||||
): Promise<{ stoppedWorktreeIds: string[] }> {
|
||||
const detectedIds = new Set(detectedWorktreeIds)
|
||||
const missingIds = [
|
||||
...new Set(
|
||||
knownWorktreeIds.filter(
|
||||
(worktreeId) =>
|
||||
splitWorktreeId(worktreeId)?.repoId === repo.id && !detectedIds.has(worktreeId)
|
||||
)
|
||||
)
|
||||
]
|
||||
if (missingIds.length === 0) {
|
||||
return { stoppedWorktreeIds: [] }
|
||||
}
|
||||
|
||||
const provider = repo.connectionId
|
||||
? deps.getSshProvider(repo.connectionId)
|
||||
: deps.getLocalProvider()
|
||||
if (!provider) {
|
||||
const stoppedWorktreeIds = (
|
||||
await mapWithConcurrency(
|
||||
missingIds,
|
||||
MISSING_WORKTREE_TEARDOWN_CONCURRENCY,
|
||||
async (worktreeId) => {
|
||||
try {
|
||||
await deps.runtime.stopTerminalsForWorktree(worktreeId)
|
||||
return worktreeId
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
).filter((worktreeId): worktreeId is string => worktreeId !== null)
|
||||
return { stoppedWorktreeIds }
|
||||
}
|
||||
|
||||
const stoppedWorktreeIds = (
|
||||
await mapWithConcurrency(
|
||||
missingIds,
|
||||
MISSING_WORKTREE_TEARDOWN_CONCURRENCY,
|
||||
async (worktreeId) => {
|
||||
try {
|
||||
await killAllProcessesForWorktree(worktreeId, {
|
||||
runtime: deps.runtime,
|
||||
localProvider: provider,
|
||||
onPtyStopped: deps.onPtyStopped,
|
||||
...(repo.connectionId ? { includeLocalRegistry: false } : {})
|
||||
})
|
||||
return worktreeId
|
||||
} catch (error) {
|
||||
console.warn(`[worktree-teardown] Failed to stop missing workspace ${worktreeId}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
).filter((worktreeId): worktreeId is string => worktreeId !== null)
|
||||
|
||||
return { stoppedWorktreeIds }
|
||||
}
|
||||
@@ -31373,6 +31373,106 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(removeWorktreeLineage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revalidates missing worktrees before stopping their local provider sessions', async () => {
|
||||
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
|
||||
const survivingId = `${TEST_REPO_ID}::/tmp/surviving`
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{ id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' },
|
||||
{ id: `${survivingId}@@surviving-session`, cwd: '/tmp/surviving', title: 'shell' }
|
||||
]),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getLocalProvider: () => localProvider as never
|
||||
})
|
||||
vi.spyOn(runtime, 'listDetectedManagedWorktrees').mockResolvedValue({
|
||||
repoId: TEST_REPO_ID,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: [{ id: survivingId }] as never
|
||||
})
|
||||
|
||||
const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
|
||||
deletedId,
|
||||
survivingId
|
||||
])
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(localProvider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@deleted-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(localProvider.shutdown).not.toHaveBeenCalledWith(
|
||||
`${survivingId}@@surviving-session`,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('does not stop sessions after a non-authoritative revalidation', async () => {
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => []),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getLocalProvider: () => localProvider as never
|
||||
})
|
||||
vi.spyOn(runtime, 'listDetectedManagedWorktrees').mockResolvedValue({
|
||||
repoId: TEST_REPO_ID,
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback',
|
||||
worktrees: []
|
||||
})
|
||||
|
||||
await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
|
||||
`${TEST_REPO_ID}::/tmp/deleted`
|
||||
])
|
||||
|
||||
expect(localProvider.listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the connection-scoped repo when local and SSH repo ids collide', async () => {
|
||||
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => []),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const sshProvider = {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{ id: `${deletedId}@@ssh-session`, cwd: '/tmp/deleted', title: 'shell' }
|
||||
]),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const localRepo = store.getRepos()[0]
|
||||
const sshRepo = { ...localRepo, connectionId: 'ssh-1' }
|
||||
const runtime = new OrcaRuntimeService(
|
||||
{
|
||||
...store,
|
||||
getRepos: () => [localRepo, sshRepo]
|
||||
} as never,
|
||||
undefined,
|
||||
{
|
||||
getLocalProvider: () => localProvider as never,
|
||||
getSshProvider: (connectionId) =>
|
||||
connectionId === 'ssh-1' ? (sshProvider as never) : undefined
|
||||
}
|
||||
)
|
||||
vi.spyOn(runtime, 'listDetectedManagedWorktrees').mockResolvedValue({
|
||||
repoId: TEST_REPO_ID,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: []
|
||||
})
|
||||
|
||||
await runtime.teardownMissingManagedWorktreeTerminals(TEST_REPO_ID, [deletedId], 'ssh-1')
|
||||
|
||||
expect(sshProvider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@ssh-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(localProvider.listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hydrates runtime detected lists with instance-validated legacy lineage', async () => {
|
||||
const parentPath = join(tmpdir(), 'worktree-parent')
|
||||
const childPath = join(tmpdir(), 'worktree-child')
|
||||
|
||||
@@ -885,6 +885,7 @@ import {
|
||||
registerTerminalViewAttributesApplier
|
||||
} from './terminal-view-attribute-store'
|
||||
import { killAllProcessesForWorktree, teardownRpcDeadline } from './worktree-teardown'
|
||||
import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation'
|
||||
import {
|
||||
MobileNotificationReplayBuffer,
|
||||
type ReplayableMobileNotification
|
||||
@@ -17944,8 +17945,11 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
async listDetectedManagedWorktrees(
|
||||
repoSelector: string,
|
||||
connectionId?: string | null
|
||||
): Promise<DetectedWorktreeListResult> {
|
||||
const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId)
|
||||
const store = this.requireStore()
|
||||
if (isFolderRepo(repo)) {
|
||||
const worktrees = listRuntimeFolderWorkspaces(store, repo)
|
||||
@@ -17995,6 +17999,50 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async teardownMissingManagedWorktreeTerminals(
|
||||
repoSelector: string,
|
||||
knownWorktreeIds: readonly string[],
|
||||
connectionId?: string | null
|
||||
): Promise<{ stoppedWorktreeIds: string[] }> {
|
||||
const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId)
|
||||
const detected = await this.listDetectedManagedWorktrees(`id:${repo.id}`, connectionId)
|
||||
if (!detected.authoritative) {
|
||||
return { stoppedWorktreeIds: [] }
|
||||
}
|
||||
return stopMissingWorktreeTerminals(
|
||||
repo,
|
||||
knownWorktreeIds,
|
||||
detected.worktrees.map((worktree) => worktree.id),
|
||||
{
|
||||
runtime: this,
|
||||
getLocalProvider: () => this.getLocalProvider(),
|
||||
getSshProvider: (connectionId) => this.getSshProviderFn?.(connectionId),
|
||||
onPtyStopped: this.onPtyStopped ?? undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private resolveRepoSelectorForConnection(
|
||||
repoSelector: string,
|
||||
connectionId?: string | null
|
||||
): Promise<Repo> {
|
||||
if (connectionId === undefined) {
|
||||
return this.resolveRepoSelector(repoSelector)
|
||||
}
|
||||
const repoId = repoSelector.startsWith('id:') ? repoSelector.slice(3) : repoSelector
|
||||
const matches = this.requireStore()
|
||||
.getRepos()
|
||||
.filter(
|
||||
(repo) =>
|
||||
repo.id === repoId &&
|
||||
(repo.connectionId?.trim() || null) === (connectionId?.trim() || null)
|
||||
)
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(matches.length > 1 ? 'selector_ambiguous' : 'repo_not_found')
|
||||
}
|
||||
return Promise.resolve(matches[0])
|
||||
}
|
||||
|
||||
private isRuntimeWorktreeVisible(
|
||||
worktree: Worktree,
|
||||
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { WORKTREE_METHODS } from './worktree'
|
||||
|
||||
describe('worktree missing-terminal teardown RPC', () => {
|
||||
it('routes the connection-scoped request through the runtime owner', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
teardownMissingManagedWorktreeTerminals: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ stoppedWorktreeIds: ['repo-1::/workspace/deleted'] })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch({
|
||||
id: 'request-1',
|
||||
authToken: 'token',
|
||||
method: 'worktree.teardownMissingTerminals',
|
||||
params: {
|
||||
repo: 'repo-1',
|
||||
worktreeIds: ['repo-1::/workspace/deleted'],
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(runtime.teardownMissingManagedWorktreeTerminals).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
['repo-1::/workspace/deleted'],
|
||||
'ssh-1'
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
})
|
||||
})
|
||||
@@ -48,6 +48,11 @@ export const WorktreeDetectedListParams = z.object({
|
||||
.pipe(z.string().min(1, 'Missing repo selector'))
|
||||
})
|
||||
|
||||
export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({
|
||||
worktreeIds: z.array(z.string().min(1)).max(10_000),
|
||||
connectionId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
export const WorktreePsParams = z.object({
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
WorktreeResolvePrBase,
|
||||
WorktreeSelector,
|
||||
WorktreeSet,
|
||||
WorktreeSortOrder
|
||||
WorktreeSortOrder,
|
||||
WorktreeTeardownMissingTerminalsParams
|
||||
} from './worktree-schemas'
|
||||
|
||||
export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
@@ -38,6 +39,16 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
params: WorktreeDetectedListParams,
|
||||
handler: async (params, { runtime }) => runtime.listDetectedManagedWorktrees(params.repo)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.teardownMissingTerminals',
|
||||
params: WorktreeTeardownMissingTerminalsParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.teardownMissingManagedWorktreeTerminals(
|
||||
params.repo,
|
||||
params.worktreeIds,
|
||||
params.connectionId
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.lineageList',
|
||||
params: null,
|
||||
|
||||
@@ -83,6 +83,13 @@ const mockApi = {
|
||||
runtimeEnvironments: {
|
||||
call: runtimeEnvironmentTransportCall
|
||||
},
|
||||
runtime: {
|
||||
call: vi.fn().mockResolvedValue({
|
||||
id: 'runtime-call',
|
||||
ok: true,
|
||||
result: { stoppedWorktreeIds: [] }
|
||||
})
|
||||
},
|
||||
ephemeralVm: {
|
||||
cancelProvision: vi.fn().mockResolvedValue({ cancelled: true }),
|
||||
cleanup: vi.fn().mockResolvedValue({}),
|
||||
@@ -1038,6 +1045,66 @@ describe('fetchWorktrees', () => {
|
||||
expect(hasDismissedHugeRepoWarning(beginHugeRepoWarningProbe(hidden))).toBe(false)
|
||||
})
|
||||
|
||||
it('awaits missing-worktree terminal teardown before purging renderer state', async () => {
|
||||
const store = createTestStore()
|
||||
const deleted = makeWorktree({
|
||||
id: 'repo1::/path/deleted',
|
||||
repoId: 'repo1',
|
||||
path: '/path/deleted'
|
||||
})
|
||||
const surviving = makeWorktree({
|
||||
id: 'repo1::/path/surviving',
|
||||
repoId: 'repo1',
|
||||
path: '/path/surviving'
|
||||
})
|
||||
let finishTeardown!: () => void
|
||||
mockApi.runtime.call.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishTeardown = () =>
|
||||
resolve({
|
||||
id: 'teardown',
|
||||
ok: true,
|
||||
result: { stoppedWorktreeIds: [deleted.id] }
|
||||
})
|
||||
})
|
||||
)
|
||||
mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [surviving]))
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo1',
|
||||
path: '/path/repo1',
|
||||
displayName: 'Repo 1',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { repo1: [deleted, surviving] },
|
||||
detectedWorktreesByRepo: {
|
||||
repo1: makeDetectedResult('repo1', [deleted, surviving])
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[deleted.id]: [{ id: 'tab-deleted', worktreeId: deleted.id }]
|
||||
}
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
const refresh = store.getState().fetchWorktrees('repo1')
|
||||
await vi.waitFor(() => expect(mockApi.runtime.call).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(mockApi.runtime.call).toHaveBeenCalledWith({
|
||||
method: 'worktree.teardownMissingTerminals',
|
||||
params: { repo: 'repo1', worktreeIds: [deleted.id], connectionId: 'ssh-1' }
|
||||
})
|
||||
expect(store.getState().tabsByWorktree[deleted.id]).toBeDefined()
|
||||
|
||||
finishTeardown()
|
||||
await refresh
|
||||
|
||||
expect(store.getState().tabsByWorktree[deleted.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears a hidden dismissal across hydrated fetch-all delete and recreation', async () => {
|
||||
const store = createTestStore()
|
||||
const visible = makeWorktree({
|
||||
@@ -1174,6 +1241,7 @@ describe('fetchWorktrees', () => {
|
||||
expect(store.getState().worktreesByRepo.repo1).toEqual([fallback])
|
||||
expect(store.getState().sortEpoch).toBe(8)
|
||||
expect(result).toBe(false)
|
||||
expect(mockApi.runtime.call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not purge remembered right sidebar tabs on a transient empty refresh', async () => {
|
||||
|
||||
@@ -633,6 +633,35 @@ function isRuntimeMethodNotFoundError(error: unknown): boolean {
|
||||
return error instanceof RuntimeRpcCallError && error.code === 'method_not_found'
|
||||
}
|
||||
|
||||
async function teardownMissingWorktreeTerminalsBestEffort(
|
||||
settings: AppState['settings'],
|
||||
repoId: string,
|
||||
connectionId: string | null | undefined,
|
||||
knownWorktreeIds: readonly string[],
|
||||
detected: DetectedWorktreeListResult
|
||||
): Promise<void> {
|
||||
if (!detected.authoritative || knownWorktreeIds.length === 0) {
|
||||
return
|
||||
}
|
||||
const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id))
|
||||
const missingIds = knownWorktreeIds.filter((worktreeId) => !detectedIds.has(worktreeId))
|
||||
if (missingIds.length === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await callRuntimeRpc(
|
||||
getActiveRuntimeTarget(settings),
|
||||
'worktree.teardownMissingTerminals',
|
||||
{ repo: repoId, worktreeIds: missingIds, connectionId: connectionId ?? null },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isRuntimeMethodNotFoundError(error)) {
|
||||
console.warn(`Failed to stop terminals for missing worktrees in repo ${repoId}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a mobile-scope web pairing is denied worktree/repo RPCs (else silently empty workspaces); surface one deduped toast (stable id) instead of spamming per-repo.
|
||||
const RUNTIME_SCOPE_FORBIDDEN_TOAST_ID = 'runtime-scope-forbidden'
|
||||
|
||||
@@ -1003,6 +1032,8 @@ function detectedWorktreeRefreshKey(
|
||||
repoId: string,
|
||||
options: {
|
||||
executionHostId: ExecutionHostId
|
||||
connectionId?: string | null
|
||||
knownWorktreeIds: readonly string[]
|
||||
requireAuthoritative?: boolean
|
||||
reuseRecentCompatibilityFailure?: boolean
|
||||
}
|
||||
@@ -1029,6 +1060,8 @@ async function listDetectedWorktreesForRepoCoalesced(
|
||||
repoId: string,
|
||||
options: {
|
||||
executionHostId: ExecutionHostId
|
||||
connectionId?: string | null
|
||||
knownWorktreeIds: readonly string[]
|
||||
requireAuthoritative?: boolean
|
||||
reuseRecentCompatibilityFailure?: boolean
|
||||
}
|
||||
@@ -1060,6 +1093,13 @@ async function listDetectedWorktreesForRepoCoalesced(
|
||||
) {
|
||||
throw new Error('runtime_environment_generation_changed')
|
||||
}
|
||||
await teardownMissingWorktreeTerminalsBestEffort(
|
||||
settings,
|
||||
repoId,
|
||||
options.connectionId,
|
||||
options.knownWorktreeIds,
|
||||
result
|
||||
)
|
||||
return result
|
||||
} finally {
|
||||
if (detectedWorktreeRefreshesInFlight.get(key) === refresh) {
|
||||
@@ -2373,10 +2413,18 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const hostId = repoHostId(ownerState, repoId)
|
||||
const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId)
|
||||
const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId)
|
||||
const repoOwner = findRepoForHost(ownerState.repos, repoId, {
|
||||
hostId,
|
||||
settings: ownerState.settings
|
||||
})
|
||||
const result = await listDetectedWorktreesForRepoCoalesced(
|
||||
settingsForRepoOwner(ownerState, repoId, hostId),
|
||||
repoId,
|
||||
{ executionHostId: hostId }
|
||||
{
|
||||
executionHostId: hostId,
|
||||
connectionId: repoOwner?.connectionId,
|
||||
knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId)
|
||||
}
|
||||
)
|
||||
set((s) => {
|
||||
if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) {
|
||||
@@ -2421,6 +2469,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
: repoHostId(ownerState, repoId, options?.executionHostId)
|
||||
const ownerWasMissingAtStart = repoOwners.length === 0
|
||||
const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId)
|
||||
const repoOwner = findRepoForHost(ownerState.repos, repoId, {
|
||||
hostId,
|
||||
settings: ownerState.settings
|
||||
})
|
||||
const ownerSettings = settingsForRepoOwner(ownerState, repoId, hostId)
|
||||
const settings =
|
||||
useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId
|
||||
@@ -2428,6 +2480,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
: ownerSettings
|
||||
const detected = await listDetectedWorktreesForRepoCoalesced(settings, repoId, {
|
||||
executionHostId: hostId,
|
||||
connectionId: repoOwner?.connectionId,
|
||||
knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId),
|
||||
requireAuthoritative: options?.requireAuthoritative
|
||||
})
|
||||
if (options?.requireAuthoritative && !detected.authoritative) {
|
||||
@@ -2578,6 +2632,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const settings = settingsForKnownRepoOwner(requestStartedState.settings, r)
|
||||
const detected = await listDetectedWorktreesForRepoCoalesced(settings, r.id, {
|
||||
executionHostId: hostId,
|
||||
connectionId: r.connectionId,
|
||||
knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId),
|
||||
reuseRecentCompatibilityFailure: true
|
||||
})
|
||||
let incoming = toVisibleWorktrees(detected, hostId, setup)
|
||||
@@ -2663,7 +2719,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const detected = await listDetectedWorktreesForRepoCoalesced(
|
||||
settingsForKnownRepoOwner(requestStartedState.settings, r),
|
||||
r.id,
|
||||
{ executionHostId: hostId, reuseRecentCompatibilityFailure: true }
|
||||
{
|
||||
executionHostId: hostId,
|
||||
connectionId: r.connectionId,
|
||||
knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId),
|
||||
reuseRecentCompatibilityFailure: true
|
||||
}
|
||||
)
|
||||
let incoming = toVisibleWorktrees(detected, hostId, setup)
|
||||
const latestState = get()
|
||||
|
||||
Reference in New Issue
Block a user