Merge remote-tracking branch 'origin/main' into brennanb2025/native-chat-multiline-send

This commit is contained in:
Brennan Benson
2026-07-30 02:06:41 -07:00
9 changed files with 948 additions and 25 deletions
@@ -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 }
}
+185
View File
@@ -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')
+85 -19
View File
@@ -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
})
+12 -1
View File
@@ -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,
+193 -1
View File
@@ -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 () => {
+102 -4
View File
@@ -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') {