refactor(terminal): one PTY attribution for teardown, close and identity; delete the deprecated owner resolver (#30626)

* refactor(terminal): one PTY attribution for teardown, close and identity; delete the deprecated resolver pair

- strict ssh: route: a proven direct listing beats a same-id server proxy; other contradictions refuse
- terminal spawn's git branch takes the strict route when a host is named (rivals refuse)
- resolveTerminalPtyOwner + isUnattributedTwinWorktree replace resolveRuntimeOwnerForWorktree in
  shutdown, pane and agent hibernation, and PTY identity; close logs unroutable runtime ids
- delete getRuntimeEnvironmentIdForWorktree/resolveRuntimeOwnerForWorktree (legacy branch moved
  into the adapter), the alias golden test, the importer ratchet, its baseline and pr.yml step
- hasHostEvidence reads the owner indexes instead of scanning repos and groups

* fix(terminal): close and identity read spawn's attribution for unscoped remote ids (Opus P2)

- an unattributed twin's unscoped remote: id is skipped and logged on close, never closed locally
- setup terminals: identity and close follow spawn's runtime; raw-id teardown stays local
- drop the last stale getRuntimeEnvironmentIdForWorktree mock (tests/e2e unit suite)

* refactor(terminal): one ownership read in resolveTerminalPtyOwner

* test(terminal): pin the close-path twin skip and the two-proxy direct listing (Opus P3)

* test(terminal): keep the close-provider owner fixture's full union type (Codex P1)
This commit is contained in:
Neil
2026-10-19 01:50:57 -07:00
committed by GitHub
parent a451f472c3
commit 7ce58ec47e
28 changed files with 475 additions and 412 deletions
-4
View File
@@ -393,10 +393,6 @@ jobs:
if: needs.code_paths.outputs.static_analysis == 'true'
run: bun config/scripts/check-layout-writer-ratchets.mjs
- name: Enforce deprecated runtime-owner importer ratchet
if: needs.code_paths.outputs.static_analysis == 'true'
run: node config/scripts/check-deprecated-runtime-owner-ratchet.mjs
- name: Enforce path-literal ratchet
if: needs.code_paths.outputs.static_analysis == 'true'
run: node config/scripts/check-path-literal-ratchet.mjs
@@ -1,9 +0,0 @@
# Renderer files that import getRuntimeEnvironmentIdForWorktree / resolveRuntimeOwnerForWorktree.
# This is a RATCHET: the list may only SHRINK. Both resolvers read an ambiguous (two-host)
# worktree as "not a runtime", which callers treat as local.
# Prune after migrating a file: pnpm check:deprecated-runtime-owner-ratchet --prune
src/renderer/src/components/terminal-workspace-model.ts
src/renderer/src/lib/agent-hibernation-coordinator.ts
src/renderer/src/store/slices/terminal-tab-retirement.ts
src/renderer/src/store/terminals/terminal-pane-hibernation.ts
src/renderer/src/store/terminals/terminal-shutdown.ts
+3 -6
View File
@@ -304,7 +304,6 @@
1 src/renderer/src/lib/agent-background-session-launch-host.ts
1 src/renderer/src/lib/agent-detection-target-inventory.ts
1 src/renderer/src/lib/agent-hibernation-coordinator-test-fixture.ts
1 src/renderer/src/lib/agent-hibernation-coordinator.ts
2 src/renderer/src/lib/agent-launch-route-input.ts
1 src/renderer/src/lib/agent-message-send.ts
1 src/renderer/src/lib/ai-vault-resume-platform.ts
@@ -346,7 +345,7 @@
1 src/renderer/src/lib/tab-number-shortcuts.ts
1 src/renderer/src/lib/terminal-close-route.ts
1 src/renderer/src/lib/terminal-tab-owner-activation.ts
5 src/renderer/src/lib/terminal-worktree-route.ts
6 src/renderer/src/lib/terminal-worktree-route.ts # resolveTerminalPtyOwner: teardown, close and PTY identity read spawn's attribution (round 3 F8), which retired the deprecated resolver's readers
3 src/renderer/src/lib/unread-badge-count-selector.ts
2 src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts
5 src/renderer/src/lib/workspace-browser-tab-open.ts
@@ -359,7 +358,7 @@
1 src/renderer/src/lib/worktree-jump-navigation.ts
2 src/renderer/src/lib/worktree-operation-route.ts
1 src/renderer/src/lib/worktree-row-open-in-owner.ts # reads focus through resolveOwnerWithLegacyFallback, newly counted (round 3 F2); F8 nets it down
9 src/renderer/src/lib/worktree-runtime-owner.ts # typed owner resolver, its null-collapsing wrapper, and the legacy-fallback adapter the folds move to (round 3 F0); deleting the deprecated pair (F8) nets it down
8 src/renderer/src/lib/worktree-runtime-owner.ts # typed owner resolver and the legacy-fallback adapter the folds moved to (round 3 F0); F8 deleted the deprecated pair
1 src/renderer/src/lib/worktree-sort-order-host-split.ts
1 src/renderer/src/lib/worktree-sort-order-persistence.ts # its split helper calls resolveOwnerWithLegacyFallback directly (round 3 F4); F8 nets it down
1 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts
@@ -451,7 +450,7 @@
8 src/renderer/src/store/slices/store-test-helpers.ts
1 src/renderer/src/store/slices/tabs/tabs-close-actions.ts
2 src/renderer/src/store/slices/tabs/tabs-host-mirroring.ts
2 src/renderer/src/store/slices/terminal-tab-retirement.ts
1 src/renderer/src/store/slices/terminal-tab-retirement.ts
3 src/renderer/src/store/slices/workspace-attention/workspace-unread-characterization-fixture.ts
1 src/renderer/src/store/slices/worktree-visibility-owner-settings.ts
3 src/renderer/src/store/slices/worktree-visibility-settings-write.ts
@@ -468,8 +467,6 @@
3 src/renderer/src/store/slices/worktrees/session/worktree-terminal-reference.ts
1 src/renderer/src/store/slices/worktrees/teardown/purged-structured-launch-retirement.ts
2 src/renderer/src/store/terminals/terminal-active-workspace-creation.ts
1 src/renderer/src/store/terminals/terminal-pane-hibernation.ts
1 src/renderer/src/store/terminals/terminal-shutdown.ts
1 src/renderer/src/store/terminals/terminal-tab-appearance.ts
1 src/renderer/src/store/terminals/terminal-tab-close-providers.ts
1 src/renderer/src/web/preload-api/web-app-api.ts
@@ -1,70 +0,0 @@
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { readTrackedSources, runEntryRatchet } from './ratchet-baseline.mjs'
// Ratchet on renderer files that import the deprecated runtime-owner resolvers. Both read a
// worktree that two hosts publish as "not a runtime", which callers then treat as local, so a stop,
// sleep or kill can land on the wrong machine. New code resolves the owner with its host and
// ambiguity kept; the importer list may only shrink.
const BASELINE_PATH = 'config/deprecated-runtime-owner-importers-baseline.txt'
const SCAN_ROOT = 'src/renderer/src'
const DEPRECATED_NAMES = ['getRuntimeEnvironmentIdForWorktree', 'resolveRuntimeOwnerForWorktree']
// The defining module and its own tests are not importers.
const DEFINING_FILE = 'src/renderer/src/lib/worktree-runtime-owner.ts'
// Any reference to a deprecated name in a file that loads the defining module, whatever the import
// form (named, aliased, namespace, dynamic, `.then` destructuring, re-export). Simpler than tracking
// bindings, and in production code a mention of these names is a use.
const DEPRECATED_NAME = new RegExp(`\\b(?:${DEPRECATED_NAMES.join('|')})\\b`)
const DEFINING_MODULE_SPECIFIER = /['"`][^'"`]*\/worktree-runtime-owner['"`]/
// Block and line comments; `://` inside a string (a URL) is not a line comment.
const COMMENT = /\/\*[\s\S]*?\*\/|(?<![:'"`\\])\/\/.*$/gm
export function isScannedPath(rel) {
return (
rel.startsWith(`${SCAN_ROOT}/`) &&
/\.(ts|tsx)$/.test(rel) &&
!/\.(test|spec)(?:-fixture|-support)?\.tsx?$/.test(rel) &&
rel !== DEFINING_FILE
)
}
/** True when uncommented code loads the defining module and names a deprecated resolver. */
export function importsDeprecatedRuntimeOwner(sourceText) {
const code = sourceText.replace(COMMENT, ' ')
return DEFINING_MODULE_SPECIFIER.test(code) && DEPRECATED_NAME.test(code)
}
export function collectDeprecatedRuntimeOwnerImporters(root = process.cwd()) {
const sources = readTrackedSources(root, [SCAN_ROOT], isScannedPath)
return [...sources]
.filter(([, source]) => importsDeprecatedRuntimeOwner(source))
.map(([rel]) => rel)
.sort()
}
const RATCHET = {
name: 'deprecated-runtime-owner',
baselinePath: BASELINE_PATH,
header: [
'# Renderer files that import getRuntimeEnvironmentIdForWorktree / resolveRuntimeOwnerForWorktree.',
'# This is a RATCHET: the list may only SHRINK. Both resolvers read an ambiguous (two-host)',
'# worktree as "not a runtime", which callers treat as local.',
'# Prune after migrating a file: pnpm check:deprecated-runtime-owner-ratchet --prune'
],
collect: collectDeprecatedRuntimeOwnerImporters,
fix: [
'getRuntimeEnvironmentIdForWorktree / resolveRuntimeOwnerForWorktree collapse an owner that two',
'hosts publish into "local". Resolve the owner with its host kept and refuse on ambiguity',
'instead (see src/renderer/src/lib/resolve-owner.ts).'
]
}
export function main(root = process.cwd(), arg = undefined) {
return runEntryRatchet(RATCHET, root, arg)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(main(process.cwd(), process.argv[2]))
}
@@ -1,101 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
importsDeprecatedRuntimeOwner,
isScannedPath
} from './check-deprecated-runtime-owner-ratchet.mjs'
describe('importsDeprecatedRuntimeOwner', () => {
it('finds a named import of either deprecated resolver', () => {
expect(
importsDeprecatedRuntimeOwner(
"import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'\n"
)
).toBe(true)
expect(
importsDeprecatedRuntimeOwner(
"import {\n foo,\n resolveRuntimeOwnerForWorktree\n} from './worktree-runtime-owner'\n"
)
).toBe(true)
})
it('finds an aliased import, a re-export and a dynamic import', () => {
expect(
importsDeprecatedRuntimeOwner(
"import { getRuntimeEnvironmentIdForWorktree as envFor } from '@/lib/worktree-runtime-owner'"
)
).toBe(true)
expect(
importsDeprecatedRuntimeOwner(
"export { resolveRuntimeOwnerForWorktree } from './worktree-runtime-owner'"
)
).toBe(true)
expect(
importsDeprecatedRuntimeOwner(
"const { getRuntimeEnvironmentIdForWorktree } = await import('@/lib/worktree-runtime-owner')"
)
).toBe(true)
})
it('finds a use through a namespace import or a module-valued dynamic import', () => {
expect(
importsDeprecatedRuntimeOwner(
"import * as owner from '@/lib/worktree-runtime-owner'\nowner.getRuntimeEnvironmentIdForWorktree(s, id)"
)
).toBe(true)
expect(
importsDeprecatedRuntimeOwner(
"const mod = await import('@/lib/worktree-runtime-owner')\nmod.resolveRuntimeOwnerForWorktree(s, id)"
)
).toBe(true)
})
it('finds destructuring in a promise-based dynamic import', () => {
expect(
importsDeprecatedRuntimeOwner(
"import('@/lib/worktree-runtime-owner').then(({ resolveRuntimeOwnerForWorktree }) => resolveRuntimeOwnerForWorktree(s, id))"
)
).toBe(true)
})
it('reads through comments inside an import list, and ignores a commented-out import', () => {
expect(
importsDeprecatedRuntimeOwner(
"import { /* legacy */ getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'"
)
).toBe(true)
expect(
importsDeprecatedRuntimeOwner(
"import {\n a, // keep\n resolveRuntimeOwnerForWorktree\n} from './worktree-runtime-owner'"
)
).toBe(true)
expect(
importsDeprecatedRuntimeOwner(
"// import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'\n/* owner.resolveRuntimeOwnerForWorktree() */"
)
).toBe(false)
})
it('ignores other names from the module, and same-named functions from other modules', () => {
expect(
importsDeprecatedRuntimeOwner(
"import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'"
)
).toBe(false)
expect(
importsDeprecatedRuntimeOwner(
"import { getRuntimeEnvironmentIdForWorktree } from '@/lib/other-owner'"
)
).toBe(false)
})
})
describe('isScannedPath', () => {
it('scans renderer sources only, not tests or the defining module', () => {
expect(isScannedPath('src/renderer/src/store/terminals/terminal-shutdown.ts')).toBe(true)
expect(isScannedPath('src/renderer/src/store/terminals/terminal-shutdown.test.ts')).toBe(false)
expect(isScannedPath('src/renderer/src/store/host-twin.test-fixture.ts')).toBe(false)
expect(isScannedPath('src/renderer/src/lib/worktree-runtime-owner.ts')).toBe(false)
expect(isScannedPath('src/main/runtime/foo.ts')).toBe(false)
})
})
@@ -243,7 +243,6 @@ it('pins every foreground and background step to its selected phase', () => {
['Enforce runtime Electron-import ratchet', staticPhase],
['Enforce focus-read ratchet', staticPhase],
['Enforce layout-writer ratchets', staticPhase],
['Enforce deprecated runtime-owner importer ratchet', staticPhase],
['Enforce path-literal ratchet', staticPhase],
['Enforce worktree-id composer ratchet', staticPhase],
['Enforce e2e typecheck ratchet', staticPhase],
+1 -2
View File
@@ -14,7 +14,7 @@
"audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src",
"test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:deprecated-runtime-owner-ratchet && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports",
"lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
@@ -38,7 +38,6 @@
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs",
"check:deprecated-runtime-owner-ratchet": "node config/scripts/check-deprecated-runtime-owner-ratchet.mjs",
"check:path-literal-ratchet": "node config/scripts/check-path-literal-ratchet.mjs",
"check:legacy-relay-fence-ratchet": "node config/scripts/check-legacy-relay-fence-ratchet.mjs",
"check:worktree-id-composer-ratchet": "node config/scripts/check-worktree-id-composer-ratchet.mjs",
@@ -1,7 +1,7 @@
import type { Tab } from '../../../shared/tab-types'
import type { KeybindingContext } from '../../../shared/keybindings'
import { useAppStore } from '../store'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from '@/lib/worktree-runtime-owner'
import { isEditorTabContentType } from '@/store/slices/editor/tabs/editor-tab-content-type'
export const CLOSE_DIALOG_DEBOUNCE_MS = 200
@@ -56,7 +56,7 @@ export function isPinnedVisibleTab(
}
export function getActiveWorktreeRuntimeEnvironmentId(worktreeId: string | null): string | null {
return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
return runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(useAppStore.getState(), worktreeId)
}
export function isPinnedActiveEditorTab(
@@ -22,7 +22,7 @@ import {
observeHibernationPtyBindings
} from './agent-hibernation-pane-age'
import { mergePendingTerminalInputActivity } from './terminal-input-activity-coalescing'
import { resolveRuntimeOwnerForWorktree } from './worktree-runtime-owner'
import { resolveTerminalPtyOwner } from './terminal-worktree-route'
import { toAgentHibernationRuntimeOwner } from './agent-hibernation-runtime-owner'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
@@ -111,7 +111,7 @@ function snapshotFromState(
function resolveAgentHibernationRuntimeOwner(state: AppState, worktreeId: string) {
const tabs = state.tabsByWorktree[worktreeId] ?? []
const ptyIds = tabs.flatMap((tab) => state.ptyIdsByTabId[tab.id] ?? [])
return toAgentHibernationRuntimeOwner(resolveRuntimeOwnerForWorktree(state, worktreeId), ptyIds)
return toAgentHibernationRuntimeOwner(resolveTerminalPtyOwner(state, worktreeId), ptyIds)
}
/** Worktree → owning runtime; `null` means liveness is required but no host can be asked. */
@@ -1,5 +1,5 @@
import { resolveTerminalTeardownOwner } from '@/store/terminals/terminal-teardown-owner'
import type { WorktreeRuntimeOwner } from './worktree-runtime-owner'
import type { TerminalPtyOwner } from '@/lib/terminal-worktree-route'
export type AgentHibernationRuntimeOwner =
| { kind: 'runtime'; environmentId: string }
@@ -9,7 +9,7 @@ export type AgentHibernationRuntimeOwner =
/** `ptyIds` are the worktree's bound PTYs; they only decide the owner when two hosts publish it. */
export function toAgentHibernationRuntimeOwner(
worktreeOwner: WorktreeRuntimeOwner,
worktreeOwner: TerminalPtyOwner,
ptyIds: readonly string[]
): AgentHibernationRuntimeOwner {
if (worktreeOwner.kind !== 'ambiguous') {
@@ -1,6 +1,6 @@
/**
* The deprecated `resolveRuntimeOwnerForWorktree` over the alias matrix; must not move.
* Captured by running main @ 37c314bcc960 over the whole matrix.
* What the deleted `resolveRuntimeOwnerForWorktree` answered over the owner matrix (main @
* 37c314bcc960), so its replacements can list every deliberate difference.
*/
export const LEGACY_RUNTIME_OWNER_GOLDEN: Readonly<Record<string, string | null>> = {
'focused | null': null,
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from './worktree-runtime-owner'
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
import { resolveWorktreeOperationRoute } from './worktree-operation-route'
import { resolveTerminalHostOwnership } from './terminal-worktree-route'
@@ -42,7 +42,7 @@ describe('a paired web client routes every owner through its server (#9047)', ()
webState({ activeWorktreeId: WT, activeWorkspaceExecutionHostId: 'local' }),
webState(UNSTAMPED_LOCAL_ROW)
]) {
expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBe(ENV)
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, WT)).toBe(ENV)
expect(resolveTerminalHostOwnership(state, WT, 'spawn')).toEqual({
kind: 'runtime',
runtimeEnvironmentId: ENV
@@ -64,9 +64,9 @@ describe('a paired web client routes every owner through its server (#9047)', ()
{ id: 'folder-1', projectGroupId: 'group-1', executionHostId: 'local', connectionId: null }
]
})
expect(getRuntimeEnvironmentIdForWorktree(folderState, folderWorkspaceKey('folder-1'))).toBe(
ENV
)
expect(
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(folderState, folderWorkspaceKey('folder-1'))
).toBe(ENV)
})
it('keeps an SSH target as the place and the server as the transport', () => {
@@ -88,12 +88,12 @@ describe('a paired web client routes every owner through its server (#9047)', ()
]
}
})
expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBeNull()
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, WT)).toBeNull()
})
it('leaves a desktop window local workspace on this computer', () => {
const state = webState(UNSTAMPED_LOCAL_ROW)
expect(getRuntimeEnvironmentIdForWorktree(state, WT)).toBeNull()
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, WT)).toBeNull()
expect(resolveTerminalHostOwnership(state, WT, 'spawn').kind).toBe('local-or-ssh')
})
})
@@ -1,12 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { ExecutionHostOwnerMatch } from '../../../shared/worktree-execution-host-resolution'
import type { HostAuthority } from '../../../shared/host-authority'
import {
getRuntimeEnvironmentIdForWorktree,
resolveOwnerWithLegacyFallback
} from './worktree-runtime-owner'
import { resolveOwnerWithLegacyFallback } from './worktree-runtime-owner'
import { OWNER_RESOLUTION_CASES } from './runtime-owner-alias-matrix.test-fixture'
import { LEGACY_FALLBACK_OWNER_ACCEPTANCE } from './resolve-owner-acceptance.test-fixture'
import { LEGACY_RUNTIME_OWNER_GOLDEN } from './legacy-runtime-owner-answers.test-fixture'
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
function describeMatch(match: ExecutionHostOwnerMatch<HostAuthority>): string {
@@ -50,10 +48,9 @@ describe('resolveOwnerWithLegacyFallback acceptance (round-3 design §5)', () =>
owner.kind === 'resolved' && owner.owner.endpoint.kind === 'environment'
? owner.owner.endpoint.environmentId
: null
return (
owner.kind !== 'resolved' ||
environmentId !== getRuntimeEnvironmentIdForWorktree(c.state, c.worktreeId)
)
const legacy = LEGACY_RUNTIME_OWNER_GOLDEN[c.name]
// Why: the deprecated environment-id reader collapsed `ambiguous` to `null`.
return owner.kind !== 'resolved' || environmentId !== (legacy === 'ambiguous' ? null : legacy)
})
for (const c of changed) {
expect(DELIBERATE_CHANGES[c.worktreeId], c.name).toBeDefined()
@@ -117,6 +114,5 @@ describe('resolveOwnerWithLegacyFallback probes', () => {
}
}
expect(ownerOf(withDetected, 'repo-twin-only::wt')).toBe('self@local')
expect(getRuntimeEnvironmentIdForWorktree(withDetected, 'repo-twin-only::wt')).toBeNull()
})
})
@@ -1,60 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
getRuntimeEnvironmentIdForWorktree,
resolveRuntimeOwnerForWorktree
} from './worktree-runtime-owner'
import { resolveOwner } from './resolve-owner'
import { OWNER_RESOLUTION_CASES } from './runtime-owner-alias-matrix.test-fixture'
import { LEGACY_RUNTIME_OWNER_GOLDEN } from './runtime-owner-alias-golden.test-fixture'
describe('the deprecated runtime-owner resolvers', () => {
it('map every case exactly as on main before resolveOwner replaced them', () => {
const actual = Object.fromEntries(
OWNER_RESOLUTION_CASES.map((c) => {
const owner = resolveRuntimeOwnerForWorktree(c.state, c.worktreeId)
const environmentId = getRuntimeEnvironmentIdForWorktree(c.state, c.worktreeId)
expect(environmentId, c.name).toBe(owner.kind === 'runtime' ? owner.environmentId : null)
const value =
owner.kind === 'runtime'
? owner.environmentId
: owner.kind === 'ambiguous'
? 'ambiguous'
: null
return [c.name, value]
})
)
expect(actual).toEqual(LEGACY_RUNTIME_OWNER_GOLDEN)
})
})
describe('resolveOwner on the deprecated resolvers’ blind spots', () => {
const byName = new Map(OWNER_RESOLUTION_CASES.map((c) => [c.name, c]))
const ownerOf = (name: string) => {
const c = byName.get(name)
if (!c?.worktreeId) {
throw new Error(`no case ${name}`)
}
return resolveOwner(c.state, { workspaceId: c.worktreeId })
}
it('refuses folder and project-group twins the deprecated resolver sends to the focused runtime', () => {
expect(LEGACY_RUNTIME_OWNER_GOLDEN['single focus | folder:twin-folder']).toBe('focused-env')
expect(ownerOf('single focus | folder:twin-folder')).toEqual({ kind: 'ambiguous' })
expect(LEGACY_RUNTIME_OWNER_GOLDEN['single focus | folder:group-twin-folder']).toBe(
'focused-env'
)
expect(ownerOf('single focus | folder:group-twin-folder')).toEqual({ kind: 'ambiguous' })
})
it('keeps the SSH target behind a server the deprecated resolver reduces to an environment id', () => {
expect(LEGACY_RUNTIME_OWNER_GOLDEN['focused | hub-repo::wt']).toBe('hub-env')
expect(ownerOf('focused | hub-repo::wt')).toEqual({
kind: 'resolved',
owner: { endpoint: { kind: 'environment', environmentId: 'hub-env' }, at: 'ssh:box' }
})
})
it('keeps git-worktree twins ambiguous, as the deprecated resolver already does', () => {
expect(ownerOf('focused | twin-repo::wt')).toEqual({ kind: 'ambiguous' })
})
})
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import { resolveTerminalHostOwnership, resolveTerminalPtyOwner } from './terminal-worktree-route'
import { resolveOwner } from './resolve-owner'
import { OWNER_RESOLUTION_CASES } from './runtime-owner-alias-matrix.test-fixture'
import { LEGACY_RUNTIME_OWNER_GOLDEN } from './legacy-runtime-owner-answers.test-fixture'
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
const describeOwner = (owner: ReturnType<typeof resolveTerminalPtyOwner>) =>
owner.kind === 'runtime' ? owner.environmentId : owner.kind === 'ambiguous' ? 'ambiguous' : null
// Each deliberate change from the deleted resolver's teardown answer (round-3 design §3, §4, §7).
const DELIBERATE_CHANGES: Record<string, string | null> = {
// C: teardown follows spawn, which already starts these PTYs on the server.
'focused | stale-hub-repo::wt': 'hub-env',
'no focus | stale-hub-repo::wt': 'hub-env',
'single focus | stale-hub-repo::wt': 'hub-env',
// D77: a folder twin names no host; only each PTY's own `remote:<env>` id may.
'focused | folder:twin-folder': 'ambiguous',
'no focus | folder:twin-folder': 'ambiguous',
'single focus | folder:twin-folder': 'ambiguous',
'focused | folder:group-twin-folder': 'ambiguous',
'no focus | folder:group-twin-folder': 'ambiguous',
'single focus | folder:group-twin-folder': 'ambiguous',
// B: two servers contest the selected SSH host, so spawn refuses and teardown must too.
'active ssh:box | rival-repo::wt': 'ambiguous',
'active ssh:box | conflict-repo::wt': 'ambiguous'
}
describe('resolveTerminalPtyOwner acceptance', () => {
it('answers as the deleted resolver did, except the deliberate cases', () => {
const actual = Object.fromEntries(
OWNER_RESOLUTION_CASES.map((c) => [
c.name,
describeOwner(resolveTerminalPtyOwner(c.state, c.worktreeId))
])
)
expect(actual).toEqual({ ...LEGACY_RUNTIME_OWNER_GOLDEN, ...DELIBERATE_CHANGES })
for (const name of Object.keys(DELIBERATE_CHANGES)) {
expect(LEGACY_RUNTIME_OWNER_GOLDEN[name], name).not.toBe(DELIBERATE_CHANGES[name])
}
})
it('keeps a setup terminal and a row-less worktree local under single focus', () => {
const single = OWNER_RESOLUTION_CASES.find((c) => c.name === 'single focus | legacy-repo::wt')!
expect(resolveTerminalPtyOwner(single.state, 'ephemeral-setup-terminal:skill-panel')).toEqual({
kind: 'non-runtime'
})
expect(resolveTerminalPtyOwner(single.state, 'unknown-repo::wt')).toEqual({
kind: 'non-runtime'
})
})
})
describe('the contested-target rule for a selected ssh: host', () => {
const directAndProxied: WorktreeRuntimeOwnerState = {
settings: { activeRuntimeEnvironmentId: null },
runtimeEnvironments: [{ id: 'hub-env' }],
runtimeEnvironmentCatalogHydrated: true,
repos: [
{ id: 'repo', connectionId: 'box', executionHostId: 'ssh:box' },
{ id: 'repo', connectionId: 'box', executionHostId: 'runtime:hub-env' }
],
worktreesByRepo: { repo: [{ id: 'repo::wt', repoId: 'repo', hostId: 'ssh:box' }] },
activeWorktreeId: 'repo::wt',
activeWorkspaceExecutionHostId: 'ssh:box'
}
it('dials a proven direct listing directly although a server proxies the same target id', () => {
expect(resolveOwner(directAndProxied, { workspaceId: 'repo::wt' })).toEqual({
kind: 'resolved',
owner: { endpoint: { kind: 'self' }, at: 'ssh:box' }
})
expect(resolveTerminalHostOwnership(directAndProxied, 'repo::wt', 'spawn').kind).toBe(
'local-or-ssh'
)
expect(resolveTerminalPtyOwner(directAndProxied, 'repo::wt')).toEqual({ kind: 'non-runtime' })
})
it('refuses when no direct repo row backs the listing', () => {
const proxiedTwice: WorktreeRuntimeOwnerState = {
...directAndProxied,
repos: [
{ id: 'repo', connectionId: 'box', executionHostId: 'runtime:hub-a' },
{ id: 'repo', connectionId: 'box', executionHostId: 'runtime:hub-b' }
]
}
expect(resolveOwner(proxiedTwice, { workspaceId: 'repo::wt' })).toEqual({ kind: 'ambiguous' })
expect(resolveTerminalHostOwnership(proxiedTwice, 'repo::wt', 'spawn').kind).toBe('unresolved')
expect(resolveTerminalPtyOwner(proxiedTwice, 'repo::wt')).toEqual({ kind: 'ambiguous' })
})
it('still dials a proven direct listing when two servers proxy the same target id', () => {
const directAndTwoProxies: WorktreeRuntimeOwnerState = {
...directAndProxied,
repos: [
{ id: 'repo', connectionId: 'box', executionHostId: 'ssh:box' },
{ id: 'repo', connectionId: 'box', executionHostId: 'runtime:hub-a' },
{ id: 'repo', connectionId: 'box', executionHostId: 'runtime:hub-b' }
]
}
expect(resolveOwner(directAndTwoProxies, { workspaceId: 'repo::wt' })).toEqual({
kind: 'resolved',
owner: { endpoint: { kind: 'self' }, at: 'ssh:box' }
})
expect(resolveTerminalPtyOwner(directAndTwoProxies, 'repo::wt')).toEqual({
kind: 'non-runtime'
})
})
})
@@ -4,10 +4,19 @@ import { parseExecutionHostId } from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { AppState } from '@/store/types'
import { resolveOwnerWithLegacyFallback } from './worktree-runtime-owner'
import { runtimeEnvironmentIdUnlessTwin } from './resolve-owner'
import { namedHostForOwnerRef, runtimeEnvironmentIdUnlessTwin } from './resolve-owner'
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
import { resolveWorktreeOperationRouteResult } from './worktree-operation-route'
import {
resolveStrictWorktreeOperationRouteResultForHost,
resolveWorktreeOperationRouteResult,
type WorktreeOperationRouteResolution
} from './worktree-operation-route'
import { getSingleFocusedRuntimeEnvironmentId } from './single-runtime-legacy-owner'
import {
hasIndexedDetectedWorktree,
resolveIndexedRepoOwner,
resolveIndexedWorktreeOwner
} from './worktree-runtime-owner-index'
export type TerminalWorktreeRoute = {
runtimeEnvironmentId: string | null
@@ -86,7 +95,7 @@ export function resolveTerminalHostOwnership(
getSingleFocusedRuntimeEnvironmentId(state)
)
}
const resolution = resolveWorktreeOperationRouteResult(state, worktreeId)
const resolution = resolveGitWorktreeRoute(state, worktreeId)
if (resolution.kind === 'resolved') {
if (resolution.route.runtimeEnvironmentId) {
// Why: a real worktree row keeps its runtime owner on teardown. Unlike the host-agnostic
@@ -106,12 +115,86 @@ export function resolveTerminalHostOwnership(
state.repos === undefined
) {
// Why: narrow unit/legacy adapters can omit all owner catalogs; production stores always provide
// them and still fail closed above. Teardown never takes this fail-open — it would kill on a guess.
// them and still fail closed above. The `teardown` purpose never takes this fail-open.
return ownershipForRuntimeEnvironmentId(getSingleFocusedRuntimeEnvironmentId(state))
}
return UNRESOLVED_TERMINAL_HOST
}
/** A named host takes the strict route, so rival servers for one `ssh:` target never read as direct. */
function resolveGitWorktreeRoute(
state: WorktreeRuntimeOwnerState,
worktreeId: string
): WorktreeOperationRouteResolution {
const hostId = namedHostForOwnerRef(state, { workspaceId: worktreeId })
return hostId
? resolveStrictWorktreeOperationRouteResultForHost(state, worktreeId, hostId)
: resolveWorktreeOperationRouteResult(state, worktreeId)
}
export type TerminalPtyOwner =
| { kind: 'runtime'; environmentId: string }
| { kind: 'non-runtime' }
| { kind: 'ambiguous' }
const NON_RUNTIME_PTY_OWNER: TerminalPtyOwner = { kind: 'non-runtime' }
const AMBIGUOUS_PTY_OWNER: TerminalPtyOwner = { kind: 'ambiguous' }
/**
* Where spawn started a worktree's PTYs, for teardown, close and PTY identity. A contested or
* unattributed-twin owner is `ambiguous`; an id no row places stays this app's, as it always has.
* `spawn` is for unscoped `remote:` ids, which live where spawn put them; `teardown` keeps a setup
* terminal's raw ids local, as they always have been.
*/
export function resolveTerminalPtyOwner(
state: WorktreeRuntimeOwnerState,
worktreeId: string | null | undefined,
purpose: TerminalHostOwnershipPurpose = 'teardown'
): TerminalPtyOwner {
if (!worktreeId) {
return NON_RUNTIME_PTY_OWNER
}
const setup = isEphemeralSetupTerminalWorktreeId(worktreeId)
if (
!setup &&
!namedHostForOwnerRef(state, { workspaceId: worktreeId }) &&
isUnattributedTwinWorktree(state, worktreeId)
) {
return AMBIGUOUS_PTY_OWNER
}
const ownership = resolveTerminalHostOwnership(state, worktreeId, setup ? purpose : 'spawn')
switch (ownership.kind) {
case 'runtime':
return { kind: 'runtime', environmentId: ownership.runtimeEnvironmentId }
case 'local-or-ssh':
return NON_RUNTIME_PTY_OWNER
case 'unresolved':
return setup ||
(parseWorkspaceKey(worktreeId)?.type !== 'folder' &&
resolveGitWorktreeRoute(state, worktreeId).kind === 'missing')
? NON_RUNTIME_PTY_OWNER
: AMBIGUOUS_PTY_OWNER
}
}
/**
* An unstamped worktree row with no detected copy, in a project two hosts publish: no row says
* which host started its PTYs, so only each PTY's own `remote:<env>` id may.
*/
export function isUnattributedTwinWorktree(
state: WorktreeRuntimeOwnerState,
worktreeId: string
): boolean {
const worktree = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId)
return (
worktree.kind === 'resolved' &&
!worktree.owner.runtimeOwnerEnvironmentId?.trim() &&
!parseExecutionHostId(worktree.owner.hostId) &&
!hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) &&
resolveIndexedRepoOwner(state.repos, worktree.owner.repoId).kind === 'ambiguous'
)
}
/**
* Host ownership for the host-agnostic surfaces — floating, folder workspace, ephemeral setup.
* Safe to downgrade on teardown because these never hold an `ssh:`-shaped HUB wake hint: a
@@ -1,5 +1,6 @@
import type { AppState } from '@/store/types'
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId
@@ -136,7 +137,8 @@ export function resolveWorktreeOperationRouteForHost(
/**
* Strict variant of the host-qualified resolver for callers that treat a null runtime as local:
* rival HUBs projecting the same host report `ambiguous` instead of a transport-less route.
* rival HUBs projecting the same host, or rows that contradict each other about its transport,
* report `ambiguous` instead of a transport-less route.
*/
export function resolveStrictWorktreeOperationRouteResultForHost(
state: WorktreeOperationRouteState,
@@ -149,13 +151,34 @@ export function resolveStrictWorktreeOperationRouteResultForHost(
}
if (host.kind === 'ssh') {
const hubOwners = collectHubOwnerEnvironmentIds(state, worktreeId, host.id)
if (hubOwners.hasAmbiguousOwner || hubOwners.environmentIds.size > 1) {
if (
hubOwners.environmentIds.size > 1 ||
(hubOwners.hasAmbiguousOwner && !isDirectListing(state, worktreeId, host.id, hubOwners))
) {
return { kind: 'ambiguous' }
}
}
return { kind: 'resolved', route: resolveSelectedHostRoute(state, worktreeId, host) }
}
/** A direct repo row backs a listing no row ties to a server, so a same-id proxy cannot claim it. */
function isDirectListing(
state: WorktreeOperationRouteState,
worktreeId: string,
executionHostId: ExecutionHostId,
hubOwners: { environmentIds: Set<string> }
): boolean {
const repoId = getRepoIdFromWorktreeId(worktreeId)
return (
hubOwners.environmentIds.size === 0 &&
Boolean(
state.repos?.some(
(repo) => repo.id === repoId && getRepoExecutionHostId(repo) === executionHostId
)
)
)
}
function collectHubOwnerEnvironmentIds(
state: WorktreeOperationRouteState,
worktreeId: string,
@@ -7,7 +7,7 @@ import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-act
import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent.test-fixture'
import { makePaneKey } from '../../../shared/stable-pane-id'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from './worktree-runtime-owner'
import {
markHostSessionMirrorHydrated,
resetHostSessionMirrorHydrationForTests
@@ -324,9 +324,9 @@ describe('preserved-pane replacement contract on workspace activation', () => {
}
}))
expect(getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktree.id)).toBe(
RUNTIME_ENV_ID
)
expect(
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(useAppStore.getState(), worktree.id)
).toBe(RUNTIME_ENV_ID)
activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
await waitForWorktreeAgentActivationGateForTests(worktree.id)
@@ -3,9 +3,9 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import {
getExecutionHostIdForWorktree,
getKnownExecutionHostIdForWorktree,
getRuntimeEnvironmentIdForWorktree,
resolveRuntimeOwnerForWorktree
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin
} from './worktree-runtime-owner'
import { resolveTerminalPtyOwner } from './terminal-worktree-route'
import { getRuntimeSessionMirrorEnvironmentIds } from './runtime-session-mirror-owners'
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
import { runtimeEnvironmentIdForWorkspaceOwner } from './resolve-owner'
@@ -44,13 +44,15 @@ const state: WorktreeRuntimeOwnerState = {
]
}
describe('getRuntimeEnvironmentIdForWorktree', () => {
describe('runtimeEnvironmentIdWithLegacyFallbackUnlessTwin', () => {
it('routes to the runtime owner of the worktree', () => {
expect(getRuntimeEnvironmentIdForWorktree(state, 'runtime-repo::wt-b')).toBe('owner-env')
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'runtime-repo::wt-b')).toBe(
'owner-env'
)
})
it('keeps explicit-local worktrees local even while a runtime is focused', () => {
expect(getRuntimeEnvironmentIdForWorktree(state, 'local-repo::wt-a')).toBeNull()
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'local-repo::wt-a')).toBeNull()
})
it('keeps the synthetic floating workspace local while a runtime is focused', () => {
@@ -60,7 +62,7 @@ describe('getRuntimeEnvironmentIdForWorktree', () => {
activeWorkspaceExecutionHostId: 'runtime:focused-env'
}
expect(
getRuntimeEnvironmentIdForWorktree(floatingState, FLOATING_TERMINAL_WORKTREE_ID)
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(floatingState, FLOATING_TERMINAL_WORKTREE_ID)
).toBeNull()
expect(getKnownExecutionHostIdForWorktree(floatingState, FLOATING_TERMINAL_WORKTREE_ID)).toBe(
'local'
@@ -71,7 +73,9 @@ describe('getRuntimeEnvironmentIdForWorktree', () => {
})
it('routes folder workspaces to their project group runtime owner', () => {
expect(getRuntimeEnvironmentIdForWorktree(state, 'folder:runtime-folder')).toBe('folder-env')
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'folder:runtime-folder')).toBe(
'folder-env'
)
expect(getExecutionHostIdForWorktree(state, 'folder:runtime-folder')).toBe('runtime:folder-env')
})
@@ -106,9 +110,12 @@ describe('getRuntimeEnvironmentIdForWorktree', () => {
}
}
expect(getRuntimeEnvironmentIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe(
'restored-env'
)
expect(
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(
restoredFolderState,
'folder:restored-folder'
)
).toBe('restored-env')
expect(ownerEnvironmentId(restoredFolderState, 'folder:restored-folder')).toBe('restored-env')
expect(getExecutionHostIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe(
'runtime:restored-env'
@@ -116,7 +123,9 @@ describe('getRuntimeEnvironmentIdForWorktree', () => {
})
it('keeps explicit-local folder workspaces local even while a runtime is focused', () => {
expect(getRuntimeEnvironmentIdForWorktree(state, 'folder:local-folder')).toBeNull()
expect(
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'folder:local-folder')
).toBeNull()
expect(getExecutionHostIdForWorktree(state, 'folder:local-folder')).toBe('local')
const restoredOwnerState: WorktreeRuntimeOwnerState = {
@@ -125,7 +134,9 @@ describe('getRuntimeEnvironmentIdForWorktree', () => {
'folder:local-folder': 'runtime:stale-env'
}
}
expect(getRuntimeEnvironmentIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBeNull()
expect(
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(restoredOwnerState, 'folder:local-folder')
).toBeNull()
expect(getExecutionHostIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBe('local')
})
@@ -139,7 +150,7 @@ describe('getRuntimeEnvironmentIdForWorktree', () => {
}
expect(
getRuntimeEnvironmentIdForWorktree(folderConnectionState, 'folder:folder-ssh')
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(folderConnectionState, 'folder:folder-ssh')
).toBeNull()
expect(getExecutionHostIdForWorktree(folderConnectionState, 'folder:folder-ssh')).toBe(
'ssh:folder-remote'
@@ -155,7 +166,10 @@ describe('getRuntimeEnvironmentIdForWorktree', () => {
}
expect(
getRuntimeEnvironmentIdForWorktree(staleFolderConnectionState, 'folder:runtime-folder')
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(
staleFolderConnectionState,
'folder:runtime-folder'
)
).toBe('folder-env')
expect(getExecutionHostIdForWorktree(staleFolderConnectionState, 'folder:runtime-folder')).toBe(
'runtime:folder-env'
@@ -163,7 +177,7 @@ describe('getRuntimeEnvironmentIdForWorktree', () => {
})
})
describe('resolveRuntimeOwnerForWorktree', () => {
describe('resolveTerminalPtyOwner', () => {
it('names the runtime owner, a non-runtime owner, and keeps a twin ambiguous', () => {
const twinState: WorktreeRuntimeOwnerState = {
...state,
@@ -175,18 +189,18 @@ describe('resolveRuntimeOwnerForWorktree', () => {
]
}
}
expect(resolveRuntimeOwnerForWorktree(twinState, 'runtime-repo::wt-b')).toEqual({
expect(resolveTerminalPtyOwner(twinState, 'runtime-repo::wt-b')).toEqual({
kind: 'runtime',
environmentId: 'owner-env'
})
expect(resolveRuntimeOwnerForWorktree(twinState, 'local-repo::wt-a')).toEqual({
expect(resolveTerminalPtyOwner(twinState, 'local-repo::wt-a')).toEqual({
kind: 'non-runtime'
})
expect(resolveRuntimeOwnerForWorktree(twinState, 'twin-repo::wt')).toEqual({
expect(resolveTerminalPtyOwner(twinState, 'twin-repo::wt')).toEqual({
kind: 'ambiguous'
})
// Why: read-only callers keep the old collapse to "local".
expect(getRuntimeEnvironmentIdForWorktree(twinState, 'twin-repo::wt')).toBeNull()
// Why: read-only callers keep the old collapse to "no server".
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(twinState, 'twin-repo::wt')).toBeNull()
})
})
@@ -214,9 +228,9 @@ describe('the explicit owner environment (resolveOwner)', () => {
}
expect(ownerEnvironmentId(nestedState, 'nested-repo::remote-worktree')).toBe('owner-hub')
expect(getRuntimeEnvironmentIdForWorktree(nestedState, 'nested-repo::remote-worktree')).toBe(
'owner-hub'
)
expect(
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(nestedState, 'nested-repo::remote-worktree')
).toBe('owner-hub')
expect(getExecutionHostIdForWorktree(nestedState, 'nested-repo::remote-worktree')).toBe(
'ssh:hub-private-ssh'
)
@@ -335,8 +349,12 @@ describe('the explicit owner environment (resolveOwner)', () => {
}
}
expect(getRuntimeEnvironmentIdForWorktree(multiHubState, 'repo-a::ssh-worktree')).toBe('hub-a')
expect(getRuntimeEnvironmentIdForWorktree(multiHubState, 'repo-b::ssh-worktree')).toBe('hub-b')
expect(
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(multiHubState, 'repo-a::ssh-worktree')
).toBe('hub-a')
expect(
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(multiHubState, 'repo-b::ssh-worktree')
).toBe('hub-b')
expect(getExecutionHostIdForWorktree(multiHubState, 'repo-a::ssh-worktree')).toBe(
'ssh:private-a'
)
@@ -346,7 +364,9 @@ describe('the explicit owner environment (resolveOwner)', () => {
})
it('does not treat the focused runtime as ownership for legacy-local worktrees', () => {
expect(getRuntimeEnvironmentIdForWorktree(state, 'legacy-repo::wt-legacy')).toBe('focused-env')
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(state, 'legacy-repo::wt-legacy')).toBe(
'focused-env'
)
expect(ownerEnvironmentId(state, 'legacy-repo::wt-legacy')).toBeNull()
})
@@ -373,7 +393,10 @@ describe('the explicit owner environment (resolveOwner)', () => {
expect(ownerEnvironmentId(hostOverrideState, 'runtime-repo::wt-local-override')).toBeNull()
expect(
getRuntimeEnvironmentIdForWorktree(hostOverrideState, 'runtime-repo::wt-local-override')
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(
hostOverrideState,
'runtime-repo::wt-local-override'
)
).toBeNull()
expect(
getExecutionHostIdForWorktree(hostOverrideState, 'runtime-repo::wt-local-override')
@@ -382,7 +405,10 @@ describe('the explicit owner environment (resolveOwner)', () => {
'worktree-env'
)
expect(
getRuntimeEnvironmentIdForWorktree(hostOverrideState, 'runtime-repo::wt-runtime-override')
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(
hostOverrideState,
'runtime-repo::wt-runtime-override'
)
).toBe('worktree-env')
expect(
getExecutionHostIdForWorktree(hostOverrideState, 'runtime-repo::wt-runtime-override')
@@ -469,10 +495,13 @@ describe('runtime owner identity indexes', () => {
}
for (let lookup = 0; lookup < 200; lookup += 1) {
getRuntimeEnvironmentIdForWorktree(indexedState, targetWorktreeId)
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(indexedState, targetWorktreeId)
ownerEnvironmentId(indexedState, targetWorktreeId)
getExecutionHostIdForWorktree(indexedState, targetWorktreeId)
getRuntimeEnvironmentIdForWorktree(indexedState, `folder:${targetFolderWorkspaceId}`)
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(
indexedState,
`folder:${targetFolderWorkspaceId}`
)
ownerEnvironmentId(indexedState, `folder:${targetFolderWorkspaceId}`)
getExecutionHostIdForWorktree(indexedState, `folder:${targetFolderWorkspaceId}`)
}
@@ -482,7 +511,9 @@ describe('runtime owner identity indexes', () => {
expect(repoIdReads).toBe(200)
expect(folderWorkspaceIdReads).toBe(1_000)
expect(projectGroupIdReads).toBe(1_000)
expect(getRuntimeEnvironmentIdForWorktree(indexedState, targetWorktreeId)).toBe('repo-owner')
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(indexedState, targetWorktreeId)).toBe(
'repo-owner'
)
expect(getExecutionHostIdForWorktree(indexedState, `folder:${targetFolderWorkspaceId}`)).toBe(
'runtime:folder-owner'
)
@@ -495,8 +526,10 @@ describe('runtime owner identity indexes', () => {
folderWorkspaces: [{ id: 'folder', projectGroupId: 'group' }],
projectGroups: [{ id: 'group', connectionId: null, executionHostId: 'runtime:folder-one' }]
}
expect(getRuntimeEnvironmentIdForWorktree(first, 'worktree')).toBe('repo-one')
expect(getRuntimeEnvironmentIdForWorktree(first, 'folder:folder')).toBe('folder-one')
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(first, 'worktree')).toBe('repo-one')
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(first, 'folder:folder')).toBe(
'folder-one'
)
const second: WorktreeRuntimeOwnerState = {
repos: [{ id: 'repo', connectionId: null, executionHostId: 'runtime:repo-two' }],
@@ -506,8 +539,12 @@ describe('runtime owner identity indexes', () => {
folderWorkspaces: [{ id: 'folder', projectGroupId: 'group' }],
projectGroups: [{ id: 'group', connectionId: null, executionHostId: 'runtime:folder-two' }]
}
expect(getRuntimeEnvironmentIdForWorktree(second, 'worktree')).toBe('worktree-two')
expect(getRuntimeEnvironmentIdForWorktree(second, 'folder:folder')).toBe('folder-two')
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(second, 'worktree')).toBe(
'worktree-two'
)
expect(runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(second, 'folder:folder')).toBe(
'folder-two'
)
})
})
@@ -609,7 +646,9 @@ describe('active workspace host selection', () => {
}
it('keeps the HUB transport for the active paired SSH worktree', () => {
expect(getRuntimeEnvironmentIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe('hub-a')
expect(
runtimeEnvironmentIdWithLegacyFallbackUnlessTwin(pairedHubState, PAIRED_HUB_WORKTREE_ID)
).toBe('hub-a')
expect(ownerEnvironmentId(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe('hub-a')
})
+27 -64
View File
@@ -12,6 +12,7 @@ import {
findIndexedRepoOwner as findRepoRecord,
findIndexedWorktreeOwner as findWorktreeRecord,
hasIndexedDetectedWorktree,
resolveIndexedProjectGroupOwner,
resolveIndexedRepoOwner,
resolveIndexedWorktreeOwner
} from './worktree-runtime-owner-index'
@@ -32,7 +33,6 @@ import { SELF_LOCAL_AUTHORITY, type HostAuthority } from '../../../shared/host-a
import type { ExecutionHostOwnerMatch } from '../../../shared/worktree-execution-host-resolution'
import {
getFloatingWorkspaceOperationRoute,
resolveActiveWorkspaceRoute,
resolveWorktreeOperationRouteResult
} from './worktree-operation-route'
import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route'
@@ -59,59 +59,22 @@ function getActiveWorkspaceExecutionHostId(
: null
}
export type WorktreeRuntimeOwner =
| { kind: 'runtime'; environmentId: string }
| { kind: 'non-runtime' }
// Why: the same repo id and path is published by two hosts (e.g. mid ssh-to-runtime migration).
| { kind: 'ambiguous' }
const NON_RUNTIME_OWNER: WorktreeRuntimeOwner = { kind: 'non-runtime' }
const AMBIGUOUS_OWNER: WorktreeRuntimeOwner = { kind: 'ambiguous' }
function toRuntimeOwner(environmentId: string | null | undefined): WorktreeRuntimeOwner {
return environmentId ? { kind: 'runtime', environmentId } : NON_RUNTIME_OWNER
}
/**
* @deprecated Use `resolveOwner` (`./resolve-owner`), which keeps the host and reports twins as
* `ambiguous`. Here ambiguous collapses to `null`, which callers read as local; read-only use only.
* The server the pre-`resolveOwner` resolver picked for a row that names no host (`null`: this
* app or a twin). Only {@link resolveOwnerWithLegacyFallback} reads it, after a named host,
* a twin and every stamped row have already been answered.
*/
export function getRuntimeEnvironmentIdForWorktree(
function legacyRuntimeEnvironmentId(
state: WorktreeRuntimeOwnerState,
worktreeId: string | null | undefined
worktreeId: string
): string | null {
const owner = resolveRuntimeOwnerForWorktree(state, worktreeId)
return owner.kind === 'runtime' ? owner.environmentId : null
}
/**
* @deprecated Use `resolveOwner` (`./resolve-owner`). This drops the host, reads local, SSH and
* unknown alike as `non-runtime`, and sends folder twins to the focused runtime.
*/
export function resolveRuntimeOwnerForWorktree(
state: WorktreeRuntimeOwnerState,
worktreeId: string | null | undefined
): WorktreeRuntimeOwner {
if (!worktreeId) {
return NON_RUNTIME_OWNER
}
const floatingRoute = getFloatingWorkspaceOperationRoute(worktreeId)
if (floatingRoute) {
return toRuntimeOwner(floatingRoute.runtimeEnvironmentId)
}
const activeRoute = resolveActiveWorkspaceRoute(state, worktreeId)
if (activeRoute) {
return toRuntimeOwner(activeRoute.runtimeEnvironmentId)
}
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
return toRuntimeOwner(
getRuntimeEnvironmentIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId)
)
return getRuntimeEnvironmentIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId)
}
const indexedOwner = resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId)
if (indexedOwner.kind === 'ambiguous') {
return AMBIGUOUS_OWNER
return null
}
if (indexedOwner.kind === 'resolved') {
const owner = indexedOwner.owner
@@ -121,15 +84,16 @@ export function resolveRuntimeOwnerForWorktree(
// Why: a row on a web client that names no server is still reached through its only one.
const onlyHost = getPairedWebClientEnvironmentId(state)
if (!hasDetectedOwner && (projectedRuntimeOwner || parsedHost)) {
return toRuntimeOwner(
return (
projectedRuntimeOwner ||
(parsedHost?.kind === 'runtime' ? parsedHost.environmentId : onlyHost)
(parsedHost?.kind === 'runtime' ? parsedHost.environmentId : onlyHost) ||
null
)
}
if (!hasDetectedOwner) {
const repoResolution = resolveIndexedRepoOwner(state.repos, owner.repoId)
if (repoResolution.kind === 'ambiguous') {
return AMBIGUOUS_OWNER
return null
}
if (
repoResolution.kind === 'resolved' &&
@@ -137,18 +101,13 @@ export function resolveRuntimeOwnerForWorktree(
) {
const repoHost = parseExecutionHostId(getRepoExecutionHostId(repoResolution.owner))
if (repoHost) {
return toRuntimeOwner(repoHost.kind === 'runtime' ? repoHost.environmentId : onlyHost)
return (repoHost.kind === 'runtime' ? repoHost.environmentId : onlyHost) || null
}
}
}
}
const resolution = resolveWorktreeOperationRouteResult(state, worktreeId)
if (resolution.kind === 'ambiguous') {
return AMBIGUOUS_OWNER
}
return resolution.kind === 'resolved'
? toRuntimeOwner(resolution.route.runtimeEnvironmentId)
: NON_RUNTIME_OWNER
return resolution.kind === 'resolved' ? resolution.route.runtimeEnvironmentId || null : null
}
function getFocusedRuntimeOrLocalExecutionHostId(
@@ -237,6 +196,12 @@ function hasRowHost(row: { executionHostId?: string | null; connectionId?: strin
return Boolean(row.executionHostId?.trim() || row.connectionId?.trim())
}
/** Through the owner index: rows on two hosts always include a stamped one. */
function hasStampedRepoRow(state: WorktreeRuntimeOwnerState, repoId: string): boolean {
const repo = resolveIndexedRepoOwner(state.repos, repoId)
return repo.kind === 'ambiguous' || (repo.kind === 'resolved' && hasRowHost(repo.owner))
}
/** A row that names its host; without one the deprecated resolver's guess still applies. */
function hasHostEvidence(state: WorktreeRuntimeOwnerState, workspaceId: string): boolean {
if (getFloatingWorkspaceOperationRoute(workspaceId)) {
@@ -245,19 +210,17 @@ function hasHostEvidence(state: WorktreeRuntimeOwnerState, workspaceId: string):
const scope = parseWorkspaceKey(workspaceId)
if (scope?.type === 'folder') {
const folder = findFolderWorkspaceOwner(state, scope.folderWorkspaceId)
return Boolean(
folder &&
(hasRowHost(folder) ||
state.projectGroups?.some(
(group) => group.id === folder.projectGroupId && hasRowHost(group)
))
)
if (!folder || hasRowHost(folder)) {
return Boolean(folder)
}
const group = resolveIndexedProjectGroupOwner(state.projectGroups, folder.projectGroupId)
return group.kind === 'ambiguous' || (group.kind === 'resolved' && hasRowHost(group.owner))
}
const repoId = getRepoIdFromWorktreeId(workspaceId)
return (
someWorktreeCopy(state, workspaceId, (row) =>
Boolean(row.hostId?.trim() || row.runtimeOwnerEnvironmentId?.trim())
) || Boolean(state.repos?.some((repo) => repo.id === repoId && hasRowHost(repo)))
) || hasStampedRepoRow(state, repoId)
)
}
@@ -278,7 +241,7 @@ export function resolveOwnerWithLegacyFallback(
) {
return match
}
const legacyEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, ref.workspaceId)
const legacyEnvironmentId = legacyRuntimeEnvironmentId(state, ref.workspaceId)
if (legacyEnvironmentId) {
return {
kind: 'resolved',
@@ -83,3 +83,27 @@ it.each(['row', 'layout', 'aggregate', 'relay', 'deferred', 'reconnect'] as cons
expect(hasTerminalPtyOwnerOutsidePane(current, identity, 'closing', 'original')).toBe(false)
}
)
it('keys a legacy id where spawn put it: exact for an unattributed twin, the runtime for setup', () => {
const twin: TerminalTabRetirementState = {
...state(),
repos: [
{ id: 'repo', connectionId: null, executionHostId: 'local' },
{ id: 'repo', connectionId: null, executionHostId: 'runtime:env-1' }
],
worktreesByRepo: { repo: [{ id: 'wt', repoId: 'repo' }] }
}
expect(getTerminalPtyOwnershipIdentity(twin, 'remote:session', 'wt')).toBe('pty:remote:session')
const focused: TerminalTabRetirementState = {
...state(),
settings: { activeRuntimeEnvironmentId: 'env-1' },
runtimeEnvironments: [{ id: 'env-1' }]
}
expect(
getTerminalPtyOwnershipIdentity(
focused,
'remote:session',
'ephemeral-setup-terminal:skill-panel'
)
).toBe(JSON.stringify(['runtime', 'env-1', 'session']))
})
@@ -4,9 +4,11 @@ import {
} from '../../../../shared/remote-runtime-pty-id'
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import type { AppState } from '../types'
import { resolveRuntimeOwnerForWorktree } from '@/lib/worktree-runtime-owner'
import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state'
import { resolveTerminalHostOwnership } from '@/lib/terminal-worktree-route'
import {
resolveTerminalHostOwnership,
resolveTerminalPtyOwner
} from '@/lib/terminal-worktree-route'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
@@ -61,9 +63,9 @@ export function getTerminalPtyOwnershipIdentity(
return JSON.stringify(['runtime', scopedEnvironmentId, remote.handle])
}
// Why: hydrated legacy runtime IDs omit their owner, but still refer to the same provider
// terminal as a scoped ID in the owning worktree. This reads the owner the way teardown's
// focus-aware fallback route does, so identity never disagrees with what a close retires.
const owner = resolveRuntimeOwnerForWorktree(state, worktreeId)
// terminal as a scoped ID in the owning worktree. This reads the owner spawn started it on, as
// close does, so identity never disagrees with what a close retires.
const owner = resolveTerminalPtyOwner(state, worktreeId, 'spawn')
// Why: a twin's legacy id can't be pinned to either host, so it matches only its own exact id.
if (owner.kind === 'ambiguous') {
return `pty:${ptyId}`
@@ -13,7 +13,7 @@ import {
} from '../slices/agent-status'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities'
import { resolveRuntimeOwnerForWorktree } from '@/lib/worktree-runtime-owner'
import { resolveTerminalPtyOwner } from '@/lib/terminal-worktree-route'
import { resolveTerminalTeardownOwner } from './terminal-teardown-owner'
import { removePanesAttention } from '../slices/workspace-attention/subject-attention-transitions'
import { settleAttentionRemovedBy } from '../slices/workspace-attention/attention-removal-follow-up'
@@ -32,7 +32,7 @@ export function createTerminalPaneHibernationActions(
)
const rendererShutdownPtyIds = [opts.ptyId]
const state = get()
const worktreeOwner = resolveRuntimeOwnerForWorktree(state, worktreeId)
const worktreeOwner = resolveTerminalPtyOwner(state, worktreeId)
const teardownOwner = resolveTerminalTeardownOwner(worktreeOwner, [opts.ptyId])
// Why: a twin-published worktree must route by the PTY's own owner; a remote PTY with no
// host stop to run would otherwise lose its binding while the agent keeps running.
@@ -9,7 +9,7 @@ import {
} from '../slices/agent-status'
import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state'
import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities'
import { resolveRuntimeOwnerForWorktree } from '@/lib/worktree-runtime-owner'
import { resolveTerminalPtyOwner } from '@/lib/terminal-worktree-route'
import { resolveTerminalTeardownOwner } from './terminal-teardown-owner'
import { createTerminalShutdownGuardController } from './terminal-shutdown-guards'
import { commitTerminalShutdownState } from './terminal-shutdown-state'
@@ -39,7 +39,7 @@ export function createTerminalShutdownActions(
const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds)
const backendOwnsPtyTeardown = opts?.backendOwnsPtyTeardown === true
const teardownOwner = resolveTerminalTeardownOwner(
resolveRuntimeOwnerForWorktree(get(), worktreeId),
resolveTerminalPtyOwner(get(), worktreeId),
rendererShutdownPtyIds
)
// Why: treating an unattributable owner as local skips the host stop yet still clears
@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalPtyOwner } from '@/lib/terminal-worktree-route'
import { createTestStore } from '../slices/store-test-helpers'
import { startTerminalTabProviderRetirement } from './terminal-tab-close-providers'
const mocks = vi.hoisted(() => {
const ptyOwner: { current: TerminalPtyOwner } = { current: { kind: 'non-runtime' } }
return { ptyOwner, callRuntimeRpc: vi.fn(async () => undefined) }
})
vi.mock('@/lib/terminal-worktree-route', () => ({
resolveTerminalPtyOwner: () => mocks.ptyOwner.current,
resolveTerminalWorktreeRoute: () => ({ runtimeEnvironmentId: 'env-a' })
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: mocks.callRuntimeRpc,
ensureRuntimeEnvironmentCompatible: vi.fn()
}))
function retire(): void {
startTerminalTabProviderRetirement({
localPtyTeardownOwnedExternally: false,
remoteCloseOwnedByHost: false,
state: createTestStore().getState(),
tabId: 'tab-1',
retirementPlan: {
tabId: 'tab-1',
worktreeId: 'repo::wt',
ptyIds: ['remote:handle-1'],
localOrSshPtyIds: [],
runtimeTerminals: [{ ptyId: 'remote:handle-1', environmentId: null, handle: 'handle-1' }],
cleanupOnlyPtyIds: [],
sharedPtyIds: [],
unroutablePtyIds: []
}
})
}
describe('closing an unscoped remote terminal id', () => {
let warn: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mocks.callRuntimeRpc.mockClear()
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => warn.mockRestore())
it('closes it where spawn started it', () => {
mocks.ptyOwner.current = { kind: 'runtime', environmentId: 'env-a' }
retire()
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-a' },
'terminal.close',
{ terminal: 'handle-1' }
)
})
it('skips and logs it, never closing on this app, when the owner is ambiguous', () => {
mocks.ptyOwner.current = { kind: 'ambiguous' }
retire()
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith(
'[terminal-retirement] skipped PTYs with no resolvable owner',
{
tabId: 'tab-1',
worktreeKind: expect.any(String),
count: 1
}
)
})
})
@@ -4,7 +4,10 @@ import {
} from '@/runtime/runtime-environment-revision'
import type { AppState } from '../types'
import { callRuntimeRpc, ensureRuntimeEnvironmentCompatible } from '@/runtime/runtime-rpc-client'
import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
import {
resolveTerminalPtyOwner,
resolveTerminalWorktreeRoute
} from '@/lib/terminal-worktree-route'
import {
classifyTerminalRetirementWorktree,
type TerminalTabRetirementPlan
@@ -25,16 +28,22 @@ export function startTerminalTabProviderRetirement({
tabId: string
canRetireRuntimeTerminal?: () => boolean
}): void {
const fallbackWorktreeRoute = retirementPlan.worktreeId
? resolveTerminalWorktreeRoute(state, retirementPlan.worktreeId)
: { runtimeEnvironmentId: null }
const { worktreeId } = retirementPlan
// Why: an unscoped `remote:` id of an unattributable twin must not be closed on this app.
const fallbackWorktreeRoute = !worktreeId
? { runtimeEnvironmentId: null }
: resolveTerminalPtyOwner(state, worktreeId, 'spawn').kind === 'ambiguous'
? null
: resolveTerminalWorktreeRoute(state, worktreeId)
const retirementTasks: Promise<unknown>[] = localPtyTeardownOwnedExternally
? []
: retirementPlan.localOrSshPtyIds.map(async (ptyId) => window.api.pty.kill(ptyId))
const localOrSshTaskCount = retirementTasks.length
let unroutableRuntimeTerminalCount = 0
if (!remoteCloseOwnedByHost) {
for (const terminal of retirementPlan.runtimeTerminals) {
if (!terminal.environmentId && !fallbackWorktreeRoute) {
unroutableRuntimeTerminalCount += 1
continue
}
const environmentId = terminal.environmentId ?? fallbackWorktreeRoute?.runtimeEnvironmentId
@@ -43,12 +52,13 @@ export function startTerminalTabProviderRetirement({
)
}
}
if (retirementPlan.unroutablePtyIds.length > 0) {
const unroutableCount = retirementPlan.unroutablePtyIds.length + unroutableRuntimeTerminalCount
if (unroutableCount > 0) {
// Log the worktree shape, never its id, because worktree ids embed absolute paths.
console.warn('[terminal-retirement] skipped PTYs with no resolvable owner', {
tabId,
worktreeKind: classifyTerminalRetirementWorktree(retirementPlan.worktreeId),
count: retirementPlan.unroutablePtyIds.length
count: unroutableCount
})
}
// Close remains synchronous; provider failures cannot block ownership revocation.
@@ -2,7 +2,7 @@ import {
isRemoteRuntimePtyId,
parseRemoteRuntimePtyId
} from '../../../../shared/remote-runtime-pty-id'
import type { WorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
import type { TerminalPtyOwner } from '@/lib/terminal-worktree-route'
export type TerminalTeardownOwner =
| { kind: 'resolved'; runtimeEnvironmentId: string | null }
@@ -14,7 +14,7 @@ export type TerminalTeardownOwner =
* attributed, so teardown is refused rather than treated as local.
*/
export function resolveTerminalTeardownOwner(
worktreeOwner: WorktreeRuntimeOwner,
worktreeOwner: TerminalPtyOwner,
ptyIds: readonly string[]
): TerminalTeardownOwner {
if (worktreeOwner.kind === 'runtime') {
@@ -20,7 +20,6 @@ import type { AgentJournalItemBody } from '../../src/shared/agent-session-journa
import { parseAgentJournalItemKey } from '../../src/shared/agent-session-journal-item-key'
import type { Tab } from '../../src/shared/tab-types'
import type { AppState } from '../../src/renderer/src/store/types'
import type * as WorktreeRuntimeOwnerModule from '../../src/renderer/src/lib/worktree-runtime-owner'
import { createCodexJournalTranslator } from '../../src/main/codex/codex-structured-journal-translation'
import { CODEX_COMMAND_APPROVAL_METHOD } from '../../src/main/codex/codex-structured-prompt-replies'
import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support'
@@ -59,13 +58,6 @@ vi.mock('@/store', async () => {
return { useAppStore }
})
// Partial: the status projection also resolves each chat's owner from the worktree.
vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => ({
...(await importOriginal<typeof WorktreeRuntimeOwnerModule>()),
getRuntimeEnvironmentIdForWorktree: (state: { testRuntimeOwner?: string | null }) =>
state.testRuntimeOwner ?? null
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn(),
subscribeStructuredAgentSession: vi.fn(),