mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/native-chat-multiline-send
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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)
|
||||
})
|
||||
|
||||
// Why: an agent cleaning up workspaces deletes many at once. Enumerating the
|
||||
// host once per missing worktree is O(N) relay round-trips carrying O(N^2)
|
||||
// rows — on SSH that stalls teardown for minutes.
|
||||
it('enumerates the host once for the whole sweep', async () => {
|
||||
const ids = Array.from({ length: 25 }, (_, index) => `repo-1::/workspace/wt-${index}`)
|
||||
const provider = createProvider(ids.map((id) => `${id}@@session`))
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(
|
||||
{ ...localRepo, connectionId: 'ssh-1' },
|
||||
ids,
|
||||
[],
|
||||
{ runtime: createRuntime(), getLocalProvider: () => null, getSshProvider: () => provider }
|
||||
)
|
||||
|
||||
expect(result.stoppedWorktreeIds).toHaveLength(ids.length)
|
||||
expect(provider.listProcesses).toHaveBeenCalledTimes(1)
|
||||
expect(provider.shutdown).toHaveBeenCalledTimes(ids.length)
|
||||
})
|
||||
|
||||
// Why: real providers put listProcesses/shutdown on the prototype and use `this`;
|
||||
// batching them behind a wrapper must not break that binding.
|
||||
it('keeps provider method binding intact while batching', async () => {
|
||||
class PrototypeProvider {
|
||||
listCalls = 0
|
||||
shutdownCalls: string[] = []
|
||||
private readonly sessions: { id: string; cwd: string; title: string }[]
|
||||
constructor(sessionIds: string[]) {
|
||||
this.sessions = sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' }))
|
||||
}
|
||||
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
|
||||
this.listCalls += 1
|
||||
return this.sessions
|
||||
}
|
||||
async shutdown(sessionId: string): Promise<void> {
|
||||
this.shutdownCalls.push(sessionId)
|
||||
}
|
||||
}
|
||||
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b', 'repo-1::/workspace/c']
|
||||
const provider = new PrototypeProvider(ids.map((id) => `${id}@@session`))
|
||||
|
||||
const result = await stopMissingWorktreeTerminals(
|
||||
{ ...localRepo, connectionId: 'ssh-1' },
|
||||
ids,
|
||||
[],
|
||||
{
|
||||
runtime: createRuntime(),
|
||||
getLocalProvider: () => null,
|
||||
getSshProvider: () => provider as unknown as IPtyProvider
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.stoppedWorktreeIds).toHaveLength(ids.length)
|
||||
expect(provider.listCalls).toBe(1)
|
||||
expect(provider.shutdownCalls).toHaveLength(ids.length)
|
||||
})
|
||||
|
||||
// Why: the batching must not leak past the calls it was built for. If the proxy
|
||||
// were the receiver, a provider whose own method called `this.listProcesses()`
|
||||
// would silently read this sweep's cached snapshot instead of the live host.
|
||||
it('does not serve the shared snapshot to provider-internal listProcesses', async () => {
|
||||
class SelfListingProvider {
|
||||
listCalls = 0
|
||||
constructor(private readonly sessions: { id: string; cwd: string; title: string }[]) {}
|
||||
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
|
||||
this.listCalls += 1
|
||||
return this.sessions
|
||||
}
|
||||
async shutdown(): Promise<void> {
|
||||
// A provider that re-reads live state as part of stopping.
|
||||
await this.listProcesses()
|
||||
}
|
||||
}
|
||||
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b']
|
||||
const provider = new SelfListingProvider(
|
||||
ids.map((id) => ({ id: `${id}@@session`, cwd: '/workspace', title: 'shell' }))
|
||||
)
|
||||
|
||||
await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], {
|
||||
runtime: createRuntime(),
|
||||
getLocalProvider: () => null,
|
||||
getSshProvider: () => provider as unknown as IPtyProvider
|
||||
})
|
||||
|
||||
// One shared sweep scan, plus each shutdown's own live re-read.
|
||||
expect(provider.listCalls).toBe(1 + ids.length)
|
||||
})
|
||||
|
||||
// Why: caching a rejected scan would let one transient failure suppress
|
||||
// teardown for every remaining worktree in the sweep.
|
||||
it('does not reuse a failed process scan', async () => {
|
||||
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b']
|
||||
const listProcesses = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('relay dropped'))
|
||||
.mockResolvedValue([{ id: `${ids[1]}@@session`, cwd: '/workspace', title: 'shell' }])
|
||||
const provider = { listProcesses, shutdown: vi.fn(async () => {}) } as unknown as IPtyProvider
|
||||
|
||||
await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], {
|
||||
runtime: createRuntime(),
|
||||
getLocalProvider: () => null,
|
||||
getSshProvider: () => provider
|
||||
})
|
||||
|
||||
expect(listProcesses.mock.calls.length).toBeGreaterThan(1)
|
||||
expect(provider.shutdown).toHaveBeenCalledWith(
|
||||
`${ids[1]}@@session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
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
|
||||
|
||||
// Why: every worktree in one sweep is already known-missing, so they can all be
|
||||
// served by a single point-in-time process list. Without this, an agent that
|
||||
// deletes N workspaces costs N full host enumerations — O(N) relay round-trips
|
||||
// carrying O(N^2) rows, which is minutes of stalled teardown on a remote host.
|
||||
//
|
||||
// Safe only because this sweep is best-effort. Do NOT reuse this wrapper on a
|
||||
// `requirePhysicalStop` path: that re-lists *after* shutdown to prove a PTY
|
||||
// exited, and a pre-shutdown snapshot would make the proof read stale rows.
|
||||
function withSharedProcessSnapshot(provider: IPtyProvider): IPtyProvider {
|
||||
let snapshot: Promise<Awaited<ReturnType<IPtyProvider['listProcesses']>>> | null = null
|
||||
return new Proxy(provider, {
|
||||
get(target, property) {
|
||||
if (property !== 'listProcesses') {
|
||||
// Why: bind other members to `target`, not the proxy. With the proxy as
|
||||
// receiver, a provider whose own method called `this.listProcesses()`
|
||||
// would silently read this sweep's cached snapshot instead of the live
|
||||
// host — the batching must not leak past the calls it was built for.
|
||||
const member: unknown = Reflect.get(target, property)
|
||||
return typeof member === 'function' ? member.bind(target) : member
|
||||
}
|
||||
return async (opts?: { deadlineMs?: number }) => {
|
||||
const pending = (snapshot ??= target.listProcesses(opts))
|
||||
try {
|
||||
return await pending
|
||||
} catch {
|
||||
// Why: a shared *rejection* would let one transient relay failure suppress
|
||||
// the provider sweep for every worktree in the batch — the exact leak this
|
||||
// reconciliation exists to prevent. Failures fall back to a per-caller scan,
|
||||
// restoring pre-batching isolation while success still costs one round-trip.
|
||||
if (snapshot === pending) {
|
||||
snapshot = null
|
||||
}
|
||||
return target.listProcesses(opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 ownedProvider = repo.connectionId
|
||||
? deps.getSshProvider(repo.connectionId)
|
||||
: deps.getLocalProvider()
|
||||
const provider = ownedProvider ? withSharedProcessSnapshot(ownedProvider) : ownedProvider
|
||||
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,
|
||||
// Why: the shared process snapshot is only valid while nothing needs a
|
||||
// post-shutdown re-list, so this sweep stays explicitly best-effort.
|
||||
requirePhysicalStop: false,
|
||||
...(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 }
|
||||
}
|
||||
@@ -34534,6 +34534,191 @@ 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 as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
|
||||
'listDetectedWorktreesForResolvedRepo'
|
||||
).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()
|
||||
)
|
||||
})
|
||||
|
||||
// Why (#10562): the renderer purges its state regardless of the sweep result, so
|
||||
// revalidating against a cached scan (30s TTL) that still lists an already-deleted
|
||||
// directory would strand those PTYs permanently — nothing asks a second time.
|
||||
it('revalidates against a fresh scan instead of a warm worktree-scan cache', async () => {
|
||||
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{ id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' }
|
||||
]),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(store, undefined, {
|
||||
getLocalProvider: () => localProvider as never
|
||||
})
|
||||
|
||||
// Warm the scan cache while the worktree still exists.
|
||||
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
||||
{ path: '/tmp/deleted', head: 'abc', branch: 'd', isBare: false, isMainWorktree: false }
|
||||
])
|
||||
await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [deletedId])
|
||||
expect(localProvider.shutdown).not.toHaveBeenCalled()
|
||||
|
||||
// The worktree is now gone; the cached scan must not mask that.
|
||||
vi.mocked(listWorktrees).mockResolvedValue([])
|
||||
const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
|
||||
deletedId
|
||||
])
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(localProvider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@deleted-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
})
|
||||
|
||||
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 as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
|
||||
'listDetectedWorktreesForResolvedRepo'
|
||||
).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()
|
||||
})
|
||||
|
||||
// Why: an explicit connection identity only narrows the selector, it must not
|
||||
// change the grammar. Treating the selector as a bare repo id made every
|
||||
// `path:`/`name:` selector fail repo_not_found on this path alone.
|
||||
it('resolves non-id selectors when a connection identity is supplied', async () => {
|
||||
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
|
||||
const localProvider = {
|
||||
listProcesses: vi.fn(async () => [
|
||||
{ id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' }
|
||||
]),
|
||||
shutdown: vi.fn(async () => {})
|
||||
}
|
||||
const localRepo = store.getRepos()[0]
|
||||
const runtime = new OrcaRuntimeService(
|
||||
{ ...store, getRepos: () => [localRepo, { ...localRepo, connectionId: 'ssh-1' }] } as never,
|
||||
undefined,
|
||||
{ getLocalProvider: () => localProvider as never }
|
||||
)
|
||||
vi.spyOn(
|
||||
runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
|
||||
'listDetectedWorktreesForResolvedRepo'
|
||||
).mockResolvedValue({
|
||||
repoId: TEST_REPO_ID,
|
||||
authoritative: true,
|
||||
source: 'git',
|
||||
worktrees: []
|
||||
})
|
||||
|
||||
// `path:` is ambiguous across the two rows; connectionId null selects the local one.
|
||||
const result = await runtime.teardownMissingManagedWorktreeTerminals(
|
||||
`path:${localRepo.path}`,
|
||||
[deletedId],
|
||||
null
|
||||
)
|
||||
|
||||
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
|
||||
expect(localProvider.shutdown).toHaveBeenCalledWith(
|
||||
`${deletedId}@@deleted-session`,
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
})
|
||||
|
||||
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 as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
|
||||
'listDetectedWorktreesForResolvedRepo'
|
||||
).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')
|
||||
|
||||
@@ -930,6 +930,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
|
||||
@@ -19579,8 +19580,18 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
async listDetectedManagedWorktrees(
|
||||
repoSelector: string,
|
||||
connectionId?: string | null
|
||||
): Promise<DetectedWorktreeListResult> {
|
||||
return this.listDetectedWorktreesForResolvedRepo(
|
||||
await this.resolveRepoSelectorForConnection(repoSelector, connectionId)
|
||||
)
|
||||
}
|
||||
|
||||
private async listDetectedWorktreesForResolvedRepo(
|
||||
repo: Repo
|
||||
): Promise<DetectedWorktreeListResult> {
|
||||
const store = this.requireStore()
|
||||
if (isFolderRepo(repo)) {
|
||||
const worktrees = listRuntimeFolderWorkspaces(store, repo)
|
||||
@@ -19630,6 +19641,56 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async teardownMissingManagedWorktreeTerminals(
|
||||
repoSelector: string,
|
||||
knownWorktreeIds: readonly string[],
|
||||
connectionId?: string | null
|
||||
): Promise<{ stoppedWorktreeIds: string[] }> {
|
||||
const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId)
|
||||
// Why: killing PTYs must be proven against the host right now — a cached scan
|
||||
// (30s TTL) can still list a directory git already dropped, and the renderer
|
||||
// purges its state either way, so a stale miss strands those processes for good.
|
||||
this.invalidateWorktreeScanCacheForRepo(repo.id)
|
||||
// Why: rescanning by `id:` would re-resolve the already-resolved repo, and a
|
||||
// duplicate id across hosts makes that second lookup throw selector_ambiguous
|
||||
// even though the caller's selector was unique — losing the sweep entirely.
|
||||
const detected = await this.listDetectedWorktreesForResolvedRepo(repo)
|
||||
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)
|
||||
}
|
||||
// Why: an explicit connection identity only *narrows* the selector; it must not
|
||||
// change the grammar. Matching the selector as a bare repo id would make
|
||||
// `path:`/`name:` selectors resolve to repo_not_found on this path alone.
|
||||
const wanted = connectionId?.trim() || null
|
||||
const matches = this.selectReposBySelector(repoSelector).filter(
|
||||
(repo) => (repo.connectionId?.trim() || null) === wanted
|
||||
)
|
||||
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
|
||||
@@ -26838,27 +26899,32 @@ export class OrcaRuntimeService {
|
||||
return this.store?.getAllWorkspaceLineage?.() ?? {}
|
||||
}
|
||||
|
||||
// Why: one selector grammar, so connection-scoped resolution can narrow the same
|
||||
// candidate set instead of reimplementing (and diverging from) the matching rules.
|
||||
private selectReposBySelector(selector: string): Repo[] {
|
||||
const repos = this.store?.getRepos() ?? []
|
||||
if (selector.startsWith('id:')) {
|
||||
return repos.filter((repo) => repo.id === selector.slice(3))
|
||||
}
|
||||
if (selector.startsWith('path:')) {
|
||||
return repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5)))
|
||||
}
|
||||
if (selector.startsWith('name:')) {
|
||||
return repos.filter((repo) => repo.displayName === selector.slice(5))
|
||||
}
|
||||
return repos.filter(
|
||||
(repo) =>
|
||||
repo.id === selector ||
|
||||
runtimePathsEqual(repo.path, selector) ||
|
||||
repo.displayName === selector
|
||||
)
|
||||
}
|
||||
|
||||
private async resolveRepoSelector(selector: string): Promise<Repo> {
|
||||
if (!this.store) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
const repos = this.store.getRepos()
|
||||
let candidates: Repo[]
|
||||
|
||||
if (selector.startsWith('id:')) {
|
||||
candidates = repos.filter((repo) => repo.id === selector.slice(3))
|
||||
} else if (selector.startsWith('path:')) {
|
||||
candidates = repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5)))
|
||||
} else if (selector.startsWith('name:')) {
|
||||
candidates = repos.filter((repo) => repo.displayName === selector.slice(5))
|
||||
} else {
|
||||
candidates = repos.filter(
|
||||
(repo) =>
|
||||
repo.id === selector ||
|
||||
runtimePathsEqual(repo.path, selector) ||
|
||||
repo.displayName === selector
|
||||
)
|
||||
}
|
||||
const candidates = this.selectReposBySelector(selector)
|
||||
|
||||
if (candidates.length === 1) {
|
||||
return candidates[0]
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,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,
|
||||
|
||||
@@ -123,6 +123,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({}),
|
||||
@@ -703,6 +710,126 @@ describe('fetchWorktrees', () => {
|
||||
expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Why (#10562): the scan coalesces but each caller carries its own known-id
|
||||
// snapshot, so a caller that joined an in-flight scan must still request
|
||||
// teardown — otherwise it purges renderer state and strands live PTYs.
|
||||
it('stops terminals for a caller that coalesced onto an in-flight scan', async () => {
|
||||
const store = createTestStore()
|
||||
const deleted = makeWorktree({ id: 'repo1::/p/deleted', repoId: 'repo1', path: '/p/deleted' })
|
||||
const surviving = makeWorktree({ id: 'repo1::/p/surv', repoId: 'repo1', path: '/p/surv' })
|
||||
|
||||
// Caller A starts before hydration, so its known-id snapshot is empty.
|
||||
store.setState({
|
||||
repos: [{ id: 'repo1', path: '/p/repo1', displayName: 'R', badgeColor: '#000', addedAt: 0 }],
|
||||
worktreesByRepo: {},
|
||||
detectedWorktreesByRepo: {},
|
||||
tabsByWorktree: {}
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
let releaseScan!: () => void
|
||||
const scanStarted = new Promise<void>((resolve) => {
|
||||
mockApi.worktrees.listDetected.mockImplementationOnce(async ({ repoId }) => {
|
||||
resolve()
|
||||
await new Promise<void>((release) => {
|
||||
releaseScan = release
|
||||
})
|
||||
return makeDetectedResult(repoId, [surviving])
|
||||
})
|
||||
})
|
||||
|
||||
const callerA = store.getState().fetchDetectedWorktrees('repo1')
|
||||
await scanStarted
|
||||
|
||||
// Hydration lands mid-scan: the renderer now owns `deleted` and its tabs.
|
||||
store.setState({
|
||||
worktreesByRepo: { repo1: [deleted, surviving] },
|
||||
detectedWorktreesByRepo: { repo1: makeDetectedResult('repo1', [deleted, surviving]) },
|
||||
tabsByWorktree: { [deleted.id]: [{ id: 'tab-d', worktreeId: deleted.id }] }
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
const callerB = store.getState().fetchWorktrees('repo1')
|
||||
releaseScan()
|
||||
await Promise.all([callerA, callerB])
|
||||
|
||||
// One shared scan, but the coalesced caller still asked the host to sweep.
|
||||
expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1)
|
||||
expect(mockApi.runtime.call).toHaveBeenCalledWith({
|
||||
method: 'worktree.teardownMissingTerminals',
|
||||
params: { repo: 'repo1', worktreeIds: [deleted.id], connectionId: null }
|
||||
})
|
||||
expect(store.getState().tabsByWorktree[deleted.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why: teardown rides outside the scan coalescer, so identical fan-out requests
|
||||
// must share one host sweep instead of re-scanning the host per caller.
|
||||
it('shares one teardown sweep across identical concurrent refreshes', async () => {
|
||||
const store = createTestStore()
|
||||
const deleted = makeWorktree({ id: 'repo1::/p/deleted', repoId: 'repo1', path: '/p/deleted' })
|
||||
const surviving = makeWorktree({ id: 'repo1::/p/surv', repoId: 'repo1', path: '/p/surv' })
|
||||
|
||||
store.setState({
|
||||
repos: [{ id: 'repo1', path: '/p/repo1', displayName: 'R', badgeColor: '#000', addedAt: 0 }],
|
||||
worktreesByRepo: { repo1: [deleted, surviving] },
|
||||
detectedWorktreesByRepo: { repo1: makeDetectedResult('repo1', [deleted, surviving]) },
|
||||
tabsByWorktree: { [deleted.id]: [{ id: 'tab-d', worktreeId: deleted.id }] }
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
mockApi.worktrees.listDetected.mockImplementationOnce(async (args) =>
|
||||
qualifyDetectedResult(args, makeDetectedResult('repo1', [surviving]))
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
store.getState().fetchWorktrees('repo1'),
|
||||
store.getState().fetchDetectedWorktrees('repo1'),
|
||||
store.getState().fetchWorktrees('repo1')
|
||||
])
|
||||
|
||||
expect(mockApi.runtime.call).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().tabsByWorktree[deleted.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why (#10562): "I can't reach the host" must never be read as "the worktree is
|
||||
// gone". A disconnected SSH target has no authoritative scan, so it must not
|
||||
// purge and must not kill anything on the far side.
|
||||
it('never stops terminals for a disconnected SSH target', async () => {
|
||||
const store = createTestStore()
|
||||
const deleted = makeWorktree({ id: 'repo1::/p/deleted', repoId: 'repo1', path: '/p/deleted' })
|
||||
const surviving = makeWorktree({ id: 'repo1::/p/surv', repoId: 'repo1', path: '/p/surv' })
|
||||
|
||||
store.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo1',
|
||||
path: '/p/repo1',
|
||||
displayName: 'R',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: TEST_SSH_AUTHORITY.targetId
|
||||
}
|
||||
],
|
||||
// Why: the target dropped — no connected authority for this host.
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
TEST_SSH_AUTHORITY.targetId,
|
||||
{
|
||||
targetId: TEST_SSH_AUTHORITY.targetId,
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
reconnectAttempt: 1
|
||||
}
|
||||
]
|
||||
]),
|
||||
worktreesByRepo: { repo1: [deleted, surviving] },
|
||||
detectedWorktreesByRepo: { repo1: makeDetectedResult('repo1', [deleted, surviving]) },
|
||||
tabsByWorktree: { [deleted.id]: [{ id: 'tab-d', worktreeId: deleted.id }] }
|
||||
} as unknown as Partial<AppState>)
|
||||
|
||||
await store.getState().fetchWorktrees('repo1')
|
||||
|
||||
expect(mockApi.runtime.call).not.toHaveBeenCalled()
|
||||
expect(store.getState().tabsByWorktree[deleted.id]).toBeDefined()
|
||||
})
|
||||
|
||||
it('coalesces fetchDetectedWorktrees with a matching fetchWorktrees refresh', async () => {
|
||||
const store = createTestStore()
|
||||
const refreshed = makeWorktree({
|
||||
@@ -1500,6 +1627,68 @@ 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.mockImplementationOnce(async (args) =>
|
||||
qualifyDetectedResult(args, 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({
|
||||
@@ -1557,7 +1746,9 @@ describe('fetchWorktrees', () => {
|
||||
path: '/path/surviving'
|
||||
})
|
||||
|
||||
mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [surviving]))
|
||||
mockApi.worktrees.listDetected.mockImplementationOnce(async (args) =>
|
||||
qualifyDetectedResult(args, makeDetectedResult('repo1', [surviving]))
|
||||
)
|
||||
store.setState({
|
||||
worktreesByRepo: { repo1: [] },
|
||||
detectedWorktreesByRepo: {
|
||||
@@ -1636,6 +1827,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 () => {
|
||||
|
||||
@@ -169,6 +169,10 @@ type DetectedWorktreeRefreshOptions = BackgroundRuntimeRefreshOptions & {
|
||||
executionHostId: ExecutionHostId
|
||||
requireAuthoritative?: boolean
|
||||
directSshAuthority?: DirectSshAuthority
|
||||
// Why (#10562): the caller's own view of what it is about to purge. Teardown is
|
||||
// requested per caller, so this must never be shared with a coalesced scan.
|
||||
connectionId?: string | null
|
||||
knownWorktreeIds?: readonly string[]
|
||||
}
|
||||
|
||||
type AdmittedDetectedWorktreeRefresh = {
|
||||
@@ -695,6 +699,64 @@ function isRuntimeMethodNotFoundError(error: unknown): boolean {
|
||||
return error instanceof RuntimeRpcCallError && error.code === 'method_not_found'
|
||||
}
|
||||
|
||||
// Why: teardown cannot ride the scan's coalescing (each caller has its own known-id
|
||||
// snapshot), so dedupe on the request it actually produces — identical fan-out
|
||||
// requests share one host sweep instead of re-scanning per caller.
|
||||
const missingWorktreeTeardownsInFlight = new Map<string, Promise<void>>()
|
||||
|
||||
async function teardownMissingWorktreeTerminalsBestEffort(
|
||||
settings: AppState['settings'],
|
||||
repoId: string,
|
||||
connectionId: string | null | undefined,
|
||||
// Why: refreshes that never purge omit this; an absent snapshot means
|
||||
// "nothing to reconcile", never "purge everything".
|
||||
knownWorktreeIds: readonly string[] | undefined,
|
||||
detected: DetectedWorktreeListResult
|
||||
): Promise<void> {
|
||||
if (!detected.authoritative || !knownWorktreeIds || 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
|
||||
}
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
const normalizedConnectionId = connectionId ?? null
|
||||
const key = [
|
||||
target.kind === 'local' ? 'local' : `runtime:${target.environmentId}`,
|
||||
repoId,
|
||||
normalizedConnectionId ?? '',
|
||||
[...missingIds].sort().join('\n')
|
||||
].join('\0')
|
||||
const existing = missingWorktreeTeardownsInFlight.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const teardown = (async () => {
|
||||
try {
|
||||
await callRuntimeRpc(
|
||||
target,
|
||||
'worktree.teardownMissingTerminals',
|
||||
{ repo: repoId, worktreeIds: missingIds, connectionId: normalizedConnectionId },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
} catch (error) {
|
||||
if (!isRuntimeMethodNotFoundError(error)) {
|
||||
console.warn(`Failed to stop terminals for missing worktrees in repo ${repoId}:`, error)
|
||||
}
|
||||
}
|
||||
})()
|
||||
missingWorktreeTeardownsInFlight.set(key, teardown)
|
||||
try {
|
||||
await teardown
|
||||
} finally {
|
||||
if (missingWorktreeTeardownsInFlight.get(key) === teardown) {
|
||||
missingWorktreeTeardownsInFlight.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
|
||||
@@ -1264,6 +1326,16 @@ async function listDetectedWorktreesForRepoCoalesced(
|
||||
) {
|
||||
throw new Error('runtime_environment_generation_changed')
|
||||
}
|
||||
// Why (#10562): the scan coalesces, but teardown must not — each caller carries
|
||||
// its own known-id snapshot and purges its own state, so a caller that joined
|
||||
// an in-flight scan would otherwise purge without ever stopping those terminals.
|
||||
await teardownMissingWorktreeTerminalsBestEffort(
|
||||
settings,
|
||||
repoId,
|
||||
options.connectionId,
|
||||
options.knownWorktreeIds,
|
||||
result
|
||||
)
|
||||
return {
|
||||
status: 'admitted',
|
||||
result,
|
||||
@@ -1311,6 +1383,13 @@ async function listDetectedWorktreesForRepoCoalesced(
|
||||
directSshAuthority: options.directSshAuthority
|
||||
}
|
||||
}
|
||||
await teardownMissingWorktreeTerminalsBestEffort(
|
||||
settings,
|
||||
repoId,
|
||||
options.connectionId,
|
||||
options.knownWorktreeIds,
|
||||
providerResult.result
|
||||
)
|
||||
return {
|
||||
status: 'admitted',
|
||||
result: providerResult.result,
|
||||
@@ -2955,6 +3034,10 @@ 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 parsedHost = parseExecutionHostId(hostId)
|
||||
const directSshAuthority =
|
||||
parsedHost?.kind === 'ssh'
|
||||
@@ -2966,7 +3049,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const refresh = await listDetectedWorktreesForRepoCoalesced(
|
||||
settingsForRepoOwner(ownerState, repoId, hostId),
|
||||
repoId,
|
||||
{ executionHostId: hostId, directSshAuthority }
|
||||
{
|
||||
executionHostId: hostId,
|
||||
directSshAuthority,
|
||||
connectionId: repoOwner?.connectionId,
|
||||
knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId)
|
||||
}
|
||||
)
|
||||
if (refresh.status !== 'admitted') {
|
||||
return null
|
||||
@@ -3034,6 +3122,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
? LOCAL_EXECUTION_HOST_ID
|
||||
: repoHostId(ownerState, repoId, options?.executionHostId)
|
||||
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
|
||||
@@ -3050,7 +3142,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, {
|
||||
executionHostId: hostId,
|
||||
requireAuthoritative: options?.requireAuthoritative,
|
||||
directSshAuthority
|
||||
directSshAuthority,
|
||||
connectionId: repoOwner?.connectionId,
|
||||
knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId)
|
||||
})
|
||||
if (refresh.status !== 'admitted') {
|
||||
return directCallerAuthority ? refresh.providerResult : false
|
||||
@@ -3108,7 +3202,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, {
|
||||
executionHostId: hostId,
|
||||
reuseRecentCompatibilityFailure: true,
|
||||
directSshAuthority
|
||||
directSshAuthority,
|
||||
connectionId: r.connectionId,
|
||||
knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId)
|
||||
})
|
||||
if (refresh.status !== 'admitted') {
|
||||
return
|
||||
@@ -3160,7 +3256,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
{
|
||||
executionHostId: hostId,
|
||||
reuseRecentCompatibilityFailure: true,
|
||||
directSshAuthority
|
||||
directSshAuthority,
|
||||
connectionId: r.connectionId,
|
||||
knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId)
|
||||
}
|
||||
)
|
||||
if (refresh.status !== 'admitted') {
|
||||
|
||||
Reference in New Issue
Block a user