fix(runtime): project removal and edits stay on the selected project's host (#30584)

* fix(runtime): project removal and settings writes keep to the resolved execution host

- Removing a project now closes its native Claude/Codex chats on this host and retires their tabs.
- A terminal spawn after removal is admitted only by a surviving row on the spawn's own host.
- repo.update and repo.setBaseRef write the resolved row's host, not a same-id sibling.

* fix(runtime): close project chats alongside the terminal stop, with their own floor

- A failed or budget-spent terminal stop no longer skips the chat closes.
- Spawn admission still refuses on any host once no row with the removed id survives.
This commit is contained in:
Neil
2026-10-18 23:13:50 -07:00
committed by GitHub
parent 6d8e4cfef2
commit 75b6fa2201
9 changed files with 396 additions and 30 deletions
+1 -1
View File
@@ -124,7 +124,7 @@ export async function buildPtyIpcSpawnOptions(
const runtime = ctx.deps.runtime
const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn
ctx.releaseWorktreeSpawn = acquireWorktreeSpawn
? await acquireWorktreeSpawn.call(runtime, args.worktreeId)
? await acquireWorktreeSpawn.call(runtime, args.worktreeId, { connectionId: args.connectionId })
: undefined
return null
}
+2 -1
View File
@@ -21,7 +21,8 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn
ctx.releaseWorktreeSpawn = acquireWorktreeSpawn
? await acquireWorktreeSpawn.call(runtime, args.worktreeId, {
refuseSleptWorktree: args.refuseSleptWorktree
refuseSleptWorktree: args.refuseSleptWorktree,
connectionId: args.connectionId
})
: undefined
let awaitingAnotherStart = false
@@ -339,7 +339,7 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW
getStore: () => this.store,
resolveRepo: (selector) => this.resolveRepoSelector(selector),
forgetTerminalTopology: (repoId) => this.terminalTopologyRevisionByRepoId.delete(repoId),
stopProjectTerminals: (repo, deadline) => this.stopTerminalsForProject(repo, { deadline }),
stopProjectTerminals: (repo, options) => this.stopTerminalsForProject(repo, options),
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId),
notifyReposChanged: () => this.notifyReposChanged()
@@ -0,0 +1,155 @@
/**
* A native Claude/Codex chat has no PTY or pane, so the terminal sweep of a project removal never
* sees it. The host closes those sessions and retires their chat tabs itself.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../shared/repo-types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
const hostRef: { current: unknown } = { current: null }
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
getStructuredAgentSessionHost: () => hostRef.current
}))
const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec')
const { TEST_REPO_ID, TEST_WORKTREE_ID, store } = await import('./orca-runtime-test-fixtures.spec')
type FakeRecord = {
sessionId: string
provider: 'claude' | 'codex'
location: { executionHostId: string; workspaceId: string }
lease: { claimStatus: string; deathEvidence: unknown }
}
function record(sessionId: string, workspaceId: string, executionHostId = 'local'): FakeRecord {
return {
sessionId,
provider: 'claude',
location: { executionHostId, workspaceId },
lease: { claimStatus: 'live', deathEvidence: null }
}
}
function installHost(records: FakeRecord[], detached: string[] = []) {
const held = new Set(
records.map((entry) => entry.sessionId).filter((id) => !detached.includes(id))
)
const visible = new Set(records.map((entry) => entry.sessionId))
const closed: string[] = []
hostRef.current = {
deps: {
store: {
listRecords: () => records,
getRecord: (sessionId: string) => records.find((entry) => entry.sessionId === sessionId),
getSessionTabId: (sessionId: string) => (visible.has(sessionId) ? `tab-${sessionId}` : null)
},
logger: { warn: () => {} }
},
hasSession: (sessionId: string) => held.has(sessionId),
getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...visible] }),
setSessionTabVisibility: async (sessionId: string, isVisible: boolean) => {
if (isVisible) {
visible.add(sessionId)
} else {
visible.delete(sessionId)
}
},
close: async (sessionId: string) => {
closed.push(sessionId)
held.delete(sessionId)
const entry = records.find((candidate) => candidate.sessionId === sessionId)
if (entry) {
entry.lease.claimStatus = 'released'
entry.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
}
}
}
return { closed, visible }
}
function createRuntime(rows: Repo[]) {
let current = rows
const runtime = new OrcaRuntimeService({
...store,
getRepo: (id: string) => current.find((row) => row.id === id),
getRepos: () => current,
removeProjectForHost: (id: string, hostId: string) => {
current = current.filter((row) => row.id !== id || getRepoExecutionHostId(row) !== hostId)
}
})
runtime.setPtyController({
write: () => true,
kill: vi.fn(() => false),
stopAndWait: vi.fn(async () => true),
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
return runtime
}
describe('project removal and native chat sessions', () => {
beforeEach(() => {
hostRef.current = null
})
it("closes the project's live chats and retires every chat tab, on this host only", async () => {
const hiddenWorkspace = `${TEST_REPO_ID}::/tmp/hidden-worktree`
const { closed, visible } = installHost(
[
record('live-chat', TEST_WORKTREE_ID),
record('resting-chat', hiddenWorkspace),
record('ssh-twin-chat', TEST_WORKTREE_ID, 'ssh:ssh-target'),
record('other-project-chat', 'repo-2::/tmp/other')
],
['resting-chat']
)
const runtime = createRuntime([store.getRepo(TEST_REPO_ID)!])
await expect(runtime.removeProject(TEST_REPO_ID)).resolves.toEqual({ removed: true })
expect(closed).toEqual(['live-chat'])
expect([...visible].sort()).toEqual(['other-project-chat', 'ssh-twin-chat'])
})
it('leaves the local twin chats alone when removing the SSH copy of the id', async () => {
const localRow = store.getRepo(TEST_REPO_ID)!
const sshRow: Repo = { ...localRow, path: '/srv/ssh-copy', connectionId: 'ssh-target' }
const { closed, visible } = installHost([
record('local-chat', TEST_WORKTREE_ID),
record('ssh-chat', TEST_WORKTREE_ID, 'ssh:ssh-target')
])
const runtime = createRuntime([localRow, sshRow])
await runtime.removeProject('path:/srv/ssh-copy')
expect(closed).toEqual(['ssh-chat'])
expect([...visible]).toEqual(['local-chat'])
})
it('still closes the chats when the terminal stop fails', async () => {
const { closed } = installHost([record('live-chat', TEST_WORKTREE_ID)])
const runtime = createRuntime([store.getRepo(TEST_REPO_ID)!])
runtime.registerPty('pty-1', TEST_WORKTREE_ID, null)
vi.spyOn(runtime, 'stopTerminalsForWorktree').mockRejectedValue(
new Error('runtime_unavailable')
)
await expect(runtime.removeProject(TEST_REPO_ID)).resolves.toEqual({ removed: true })
expect(closed).toEqual(['live-chat'])
})
it('still closes the chats when the terminal stops spent the removal budget', async () => {
const { closed } = installHost([record('live-chat', TEST_WORKTREE_ID)])
const runtime = createRuntime([])
await runtime.stopTerminalsForProject(store.getRepo(TEST_REPO_ID)!, {
deadline: Date.now() - 1,
closeStructuredSessions: true
})
expect(closed).toEqual(['live-chat'])
})
})
@@ -159,6 +159,63 @@ describe('project removal on the execution host', () => {
)
})
it('refuses an SSH spawn after removal even though a local same-id sibling survives', async () => {
const localRow = store.getRepo(TEST_REPO_ID)!
const sshRow: Repo = { ...localRow, path: '/srv/ssh-copy', connectionId: 'ssh-target' }
let rows = [localRow, sshRow]
const runtime = new OrcaRuntimeService({
...store,
getRepo: (id: string) => rows.find((row) => row.id === id),
getRepos: () => rows,
removeProjectForHost: () => {
rows = [localRow]
}
})
runtime.setPtyController({
write: () => true,
kill: vi.fn(() => false),
stopAndWait: vi.fn(async () => true),
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await runtime.removeProject('path:/srv/ssh-copy')
await expect(
runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID, { connectionId: 'ssh-target' })
).rejects.toThrow('repo_not_found')
const releaseLocal = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID)
releaseLocal()
})
it('refuses a spawn on any host once no row with the removed id survives', async () => {
const sshRow: Repo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-target' }
let rows = [sshRow]
const runtime = new OrcaRuntimeService({
...store,
getRepo: (id: string) => rows.find((row) => row.id === id),
getRepos: () => rows,
removeProjectForHost: () => {
rows = []
}
})
runtime.setPtyController({
write: () => true,
kill: vi.fn(() => false),
stopAndWait: vi.fn(async () => true),
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await runtime.removeProject(TEST_REPO_ID)
await expect(runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID)).rejects.toThrow(
'repo_not_found'
)
})
it('stops nothing when a same-id sibling row would claim the same PTYs', async () => {
const removeProjectForHost = vi.fn()
const stopAndWait = vi.fn(async (_ptyId: string) => true)
@@ -8,6 +8,8 @@
import { describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../shared/repo-types'
import { OrcaRuntimeService } from './orca-runtime'
import { findRepoRowForHostScopedWrite } from '../persistence/tracking-repos/repo-update-operations'
import type { ExecutionHostId } from '../../shared/execution-host'
export function registerRuntimeRemoveProjectHostScopeTests(): void {
function makeRepos(): Repo[] {
@@ -43,6 +45,7 @@ export function registerRuntimeRemoveProjectHostScopeTests(): void {
}
}
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the store stub covers every read and write repo.rm and repo.update drive.
const runtime = new OrcaRuntimeService({
getRepos: () => [...repos],
getRepo: (id: string) => repos.find((repo) => repo.id === id) ?? null,
@@ -51,6 +54,11 @@ export function registerRuntimeRemoveProjectHostScopeTests(): void {
setWorktreeMeta: vi.fn(),
removeWorktreeMeta: vi.fn(),
getGitHubCache: () => null,
// Same row lookup the real store's host-scoped write uses.
updateRepo: (id: string, updates: Partial<Repo>, hostId?: ExecutionHostId) => {
const row = findRepoRowForHostScopedWrite(repos, id, hostId)
return row ? Object.assign(row, updates) : null
},
removeProjectForHost
} as never)
return { runtime, repos, removeProjectForHost }
@@ -81,4 +89,22 @@ export function registerRuntimeRemoveProjectHostScopeTests(): void {
expect(repos).toHaveLength(2)
})
})
describe('repo.update with the same repo id on two execution hosts', () => {
it('writes the SSH row a path selector picked, not its local sibling', async () => {
const { runtime, repos } = createRuntime()
await runtime.updateRepo('path:/remote/dup', { displayName: 'Renamed Remote' })
expect(repos.map((repo) => repo.displayName)).toEqual(['Dup Local', 'Renamed Remote'])
})
it('sets the base ref on the SSH row a path selector picked', async () => {
const { runtime, repos } = createRuntime()
await runtime.setRepoBaseRef('path:/remote/dup', 'origin/dev')
expect(repos.map((repo) => repo.worktreeBaseRef)).toEqual([undefined, 'origin/dev'])
})
})
}
@@ -15,7 +15,10 @@ type RuntimeRepositorySettingsDependencies = {
resolveRepo: (selector: string) => Promise<Repo>
forgetTerminalTopology: (repoId: string) => void
/** Optional so test doubles and older wiring keep removing without it. */
stopProjectTerminals?: (repo: Repo, deadline: number) => Promise<unknown>
stopProjectTerminals?: (
repo: Repo,
options: { deadline: number; closeStructuredSessions: boolean }
) => Promise<unknown>
invalidateResolvedWorktrees: () => void
invalidateWorktreeScan: (repoId: string) => void
notifyReposChanged: () => void
@@ -70,7 +73,11 @@ export class RuntimeRepositorySettingsController {
if (isFolderRepo(repo)) {
throw new Error('Folder mode does not support base refs.')
}
const updated = store.updateRepo(repo.id, { worktreeBaseRef: baseRef })
const updated = store.updateRepo(
repo.id,
{ worktreeBaseRef: baseRef },
getRepoExecutionHostId(repo)
)
if (!updated) {
throw new Error('repo_not_found')
}
@@ -98,7 +105,12 @@ export class RuntimeRepositorySettingsController {
}
const write = (proven: Pick<Repo, 'folderUpgradeGitRootPath'>): Repo | null => {
assertWslMoveRepoWritable(store, repo)
return store.updateRepo(repo.id, { ...sanitizedUpdates, ...proven })
// Why the host: a same-id sibling on another host would take an id-only write.
return store.updateRepo(
repo.id,
{ ...sanitizedUpdates, ...proven },
getRepoExecutionHostId(repo)
)
}
const updated =
sanitizedUpdates.kind === 'git' && isFolderRepo(repo)
@@ -130,18 +142,21 @@ export class RuntimeRepositorySettingsController {
// Why here: the host owns these shells; a client only stops the workspaces it has loaded,
// and older clients stop none. Bounded so repo.rm still answers inside the client's timeout.
const deadline = Date.now() + PROJECT_TERMINAL_STOP_BUDGET_MS
const stopProjectTerminals = async (): Promise<void> => {
await this.deps.stopProjectTerminals?.(repo, deadline).catch((error: unknown) => {
console.warn('[runtime] failed to stop project terminals on removal', error)
})
const stopProjectTerminals = async (closeStructuredSessions: boolean): Promise<void> => {
await this.deps
.stopProjectTerminals?.(repo, { deadline, closeStructuredSessions })
.catch((error: unknown) => {
console.warn('[runtime] failed to stop project terminals on removal', error)
})
}
await stopProjectTerminals()
await stopProjectTerminals(false)
// Why again: a WSL move may have fenced the project while the stop was awaited.
assertWslMoveRepoWritable(store, repo)
store.removeProjectForHost(repo.id, hostId)
// Why again: a terminal spawned while the first stop ran is past that sweep's snapshot, and
// nothing can spawn into the project once it is gone.
await stopProjectTerminals()
// nothing can spawn into the project once it is gone. Chats only now: retiring their tabs is
// irreversible, and the WSL fence above can still refuse the removal.
await stopProjectTerminals(true)
this.deps.forgetTerminalTopology(repo.id)
this.deps.invalidateResolvedWorktrees()
this.deps.invalidateWorktreeScan(repo.id)
@@ -34,6 +34,7 @@ import {
import { closeStructuredAgentSessionChild } from './structured-agent-session-close'
import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement'
import type { WorktreePtyHostFence } from './worktree-pty-host-fence'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import type { OrcaRuntimeService } from './orca-runtime'
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
@@ -115,6 +116,24 @@ export type StructuredSessionsForWorktree = {
export function listStructuredSessionsForWorktree(
worktreeId: string,
fence: StructuredSessionHostFence
): StructuredSessionsForWorktree {
return listFencedStructuredSessions((workspaceId) => workspaceId === worktreeId, fence)
}
/** Structured sessions in every workspace of a project, on the fenced host only. */
export function listStructuredSessionsForProject(
repoId: string,
fence: StructuredSessionHostFence
): StructuredSessionsForWorktree {
return listFencedStructuredSessions(
(workspaceId) => getRepoIdFromWorktreeId(workspaceId) === repoId,
fence
)
}
function listFencedStructuredSessions(
inScope: (workspaceId: string) => boolean,
fence: StructuredSessionHostFence
): StructuredSessionsForWorktree {
const host = getStructuredAgentSessionHost()
if (!host) {
@@ -129,8 +148,7 @@ export function listStructuredSessionsForWorktree(
const hostId = structuredSessionTeardownHostId(fence)
const members = records
.filter(
(record) =>
record.location.workspaceId === worktreeId && record.location.executionHostId === hostId
(record) => inScope(record.location.workspaceId) && record.location.executionHostId === hostId
)
.map((record) => ({ sessionId: record.sessionId, agent: record.provider }))
return {
@@ -1,9 +1,22 @@
import { OrcaRuntimeWithStopExactTerminalsForWorktree } from './worktree-terminal-stop-targets'
import { getRepoExecutionHostId, getRepoSshConnectionId } from '../../../../shared/execution-host'
import {
getRepoExecutionHostId,
getRepoSshConnectionId,
normalizeHostPart
} from '../../../../shared/execution-host'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import type { Repo } from '../../../../shared/repo-types'
import {
closeStructuredSessionsForWorktree,
createStructuredSweepProgress,
listStructuredSessionsForProject,
retireStructuredSessionTabsForWorktree,
unclosedStructuredSessions
} from '../../structured-session-worktree-teardown'
const PROJECT_INVENTORY_REFRESH_BUDGET_MS = 3_000
// Inside repo.rm's 15 s client timeout after the 10 s terminal budget.
const PROJECT_CHAT_CLOSE_FLOOR_MS = 3_000
/** The value, or null once the deadline passes first. */
async function raceDeadline<T>(value: Promise<T>, deadline: number | undefined): Promise<T | null> {
@@ -44,17 +57,23 @@ export function listProjectTerminalWorktreeIds(
}
export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntimeWithStopExactTerminalsForWorktree {
/** Projects this host removed; ids are UUIDs, so a remaining row means a same-id sibling. */
private readonly removedProjectIds = new Set<string>()
/** Removed projects' PTY fences by id; ids are UUIDs, so a remaining row means a same-id sibling. */
private readonly removedProjectFences = new Map<string, Set<string | null>>()
override async acquireWorktreeTerminalSpawn(
worktreeId?: string,
opts: { refuseSleptWorktree?: boolean } = {}
opts: { refuseSleptWorktree?: boolean; connectionId?: string | null } = {}
): Promise<() => void> {
const release = await super.acquireWorktreeTerminalSpawn(worktreeId, opts)
const repoId = worktreeId ? getRepoIdFromWorktreeId(worktreeId) : null
const removedFences = repoId ? this.removedProjectFences.get(repoId) : undefined
// Why under the lease: a spawn that resolved its workspace before removal queues behind the sweep.
if (repoId && this.removedProjectIds.has(repoId) && !this.store?.getRepo?.(repoId)) {
// Why the fence: a surviving same-id row on another host must not admit the removed host's spawn.
if (
repoId &&
removedFences &&
!this.removedProjectAdmitsSpawn(repoId, removedFences, opts.connectionId)
) {
release()
throw new Error('repo_not_found')
}
@@ -67,7 +86,31 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime
*/
async stopTerminalsForProject(
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
options: { deadline?: number } = {}
options: { deadline?: number; closeStructuredSessions?: boolean } = {}
): Promise<{ stopped: number }> {
const { closeStructuredSessions, ...stopOptions } = options
// Why alongside, with its own floor: a failed or budget-spent terminal stop must not skip the
// chat closes, since their tabs are retired either way.
const chats = closeStructuredSessions
? this.closeStructuredSessionsForProject(
repo,
options.deadline === undefined
? undefined
: Math.max(options.deadline, Date.now() + PROJECT_CHAT_CLOSE_FLOOR_MS)
).catch((error: unknown) => {
console.warn('[runtime] project agent session close failed', error)
})
: null
try {
return await this.stopProjectPtys(repo, stopOptions)
} finally {
await chats
}
}
private async stopProjectPtys(
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
options: { deadline?: number }
): Promise<{ stopped: number }> {
// Why: after a restart, surviving daemon sessions are in the provider inventory before ptysById.
// Bounded so a slow worktree scan cannot spend the budget the known PTYs' stops need.
@@ -91,15 +134,10 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime
// Why not the row's stamp: a `runtime:` stamp is how paired clients address a project that runs
// here, natively or over its nested SSH connection.
const fence = { resolvedConnectionId: getRepoSshConnectionId(repo) }
this.removedProjectIds.add(repo.id)
// Why: a same-id sibling row with the same fence owns PTYs this table cannot tell apart.
const siblingSharesFence = (this.store?.getRepos?.() ?? []).some(
(row) =>
row.id === repo.id &&
getRepoExecutionHostId(row) !== getRepoExecutionHostId(repo) &&
getRepoSshConnectionId(row) === fence.resolvedConnectionId
)
if (siblingSharesFence) {
const removedFences = this.removedProjectFences.get(repo.id) ?? new Set<string | null>()
removedFences.add(fence.resolvedConnectionId)
this.removedProjectFences.set(repo.id, removedFences)
if (this.projectSiblingSharesFence(repo)) {
return { stopped: 0 }
}
let stopped = 0
@@ -129,4 +167,60 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime
}
return { stopped }
}
/**
* Closes a removed project's native chats on this host and retires their chat tabs. They have
* no PTY or pane, so the terminal sweep never reaches them. Best-effort: the project is gone.
*/
private async closeStructuredSessionsForProject(
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
deadline: number | undefined
): Promise<void> {
if (this.projectSiblingSharesFence(repo)) {
return
}
const { members, live } = listStructuredSessionsForProject(repo.id, {
resolvedConnectionId: getRepoSshConnectionId(repo)
})
const progress = createStructuredSweepProgress(live)
await raceDeadline(
closeStructuredSessionsForWorktree(progress, deadline ?? Infinity, { runtime: this }),
deadline
)
const liveIds = new Set(live.map((session) => session.sessionId))
await retireStructuredSessionTabsForWorktree(
[
...members.filter((session) => !liveIds.has(session.sessionId)),
...unclosedStructuredSessions(progress)
],
this
)
}
/** Only a surviving row on the spawn's own host, or any row when no removal touched it. */
private removedProjectAdmitsSpawn(
repoId: string,
removedFences: ReadonlySet<string | null>,
spawnConnectionId: string | null | undefined
): boolean {
const connectionId = normalizeHostPart(spawnConnectionId)
const survivors = (this.store?.getRepos?.() ?? []).filter((row) => row.id === repoId)
if (!removedFences.has(connectionId)) {
return survivors.length > 0
}
return survivors.some((row) => getRepoSshConnectionId(row) === connectionId)
}
/** A same-id sibling row with the same fence owns sessions this host cannot tell apart. */
private projectSiblingSharesFence(
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>
): boolean {
const connectionId = getRepoSshConnectionId(repo)
return (this.store?.getRepos?.() ?? []).some(
(row) =>
row.id === repo.id &&
getRepoExecutionHostId(row) !== getRepoExecutionHostId(repo) &&
getRepoSshConnectionId(row) === connectionId
)
}
}