From 4c49989c2eaf474a0d635acf818cbdbdf53828a5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:57:02 -0700 Subject: [PATCH 01/46] refactor(codex): delete the unreachable managed shared-mirror lane (#12614) PR 9501 shipped real-home routing for the host system default, and the env override that could turn it back off was never a shipped control. The managed-account half of the shared runtime mirror has been unreachable since: every host account routes to its own self-contained CODEX_HOME before that code runs. Delete the flag module and its env plumbing plus the managed branch of syncForCurrentSelection and the six helpers only it called. The three lanes that still use the shared mirror -- Windows, a custom CODEX_HOME, and a hook-lane gate that reports unusable -- are untouched, as are every legacy migration and the WSL read-back helpers. --- .../run-codex-real-account-validation.mjs | 25 +- .../run-codex-real-account-validation.test.ts | 20 - config/scripts/run-idle-cpu-benchmark.mjs | 1 - .../serve-headless-fresh-profile-pairing.mjs | 1 - .../verify-linux-wayland-gpu-sandbox.mjs | 1 - .../electron-dev-session.mjs | 1 - .../runtime-home-mirrored-status-home.test.ts | 11 +- ...home-service-per-account-migration.test.ts | 1 - .../runtime-home-service.test.ts | 2268 +---------------- .../codex-accounts/runtime-home-service.ts | 350 +-- ...ime-home-windows-profile-ownership.test.ts | 3 - src/main/codex-accounts/service.test.ts | 97 +- src/main/codex-accounts/service.ts | 28 +- src/main/codex/codex-real-home-flag.test.ts | 43 - src/main/codex/codex-real-home-flag.ts | 35 - src/main/ipc/pty.ts | 6 +- tests/e2e/electron-home-isolation.spec.ts | 18 +- .../headless-serve-desktop-activation.spec.ts | 3 +- tests/e2e/helpers/computer-cli-driver.ts | 3 +- tests/e2e/helpers/electron-home-isolation.ts | 10 +- .../electron-home-isolation.unit.test.ts | 27 +- .../helpers/headless-paired-runtime-host.ts | 3 +- tests/e2e/helpers/orca-app.ts | 8 +- tests/e2e/helpers/orca-restart.ts | 3 +- tests/e2e/helpers/paired-electron-client.ts | 3 +- .../benchmarks/daemon-coldstart-bench.mjs | 1 - .../benchmarks/main-thread-jank-bench.mjs | 3 +- tests/tools/benchmarks/startup-time-bench.mjs | 1 - tests/tools/win-update-e2e/app-driver.mjs | 4 +- 29 files changed, 211 insertions(+), 2767 deletions(-) delete mode 100644 src/main/codex/codex-real-home-flag.test.ts delete mode 100644 src/main/codex/codex-real-home-flag.ts diff --git a/config/scripts/run-codex-real-account-validation.mjs b/config/scripts/run-codex-real-account-validation.mjs index 53d237f22ee..9318f412ffe 100644 --- a/config/scripts/run-codex-real-account-validation.mjs +++ b/config/scripts/run-codex-real-account-validation.mjs @@ -35,7 +35,6 @@ const RESTRICTED_ENV_KEYS = [ 'HOMEPATH', 'CODEX_HOME', 'ORCA_CODEX_HOME', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_E2E_HOME_DIR', 'ORCA_E2E_USER_DATA_DIR', 'ORCA_USER_DATA_PATH', @@ -69,7 +68,7 @@ async function resolveRealPath(candidate) { } } -export function createValidationEnv(inheritedEnv, layout, options = {}) { +export function createValidationEnv(inheritedEnv, layout) { const env = { ...inheritedEnv } for (const key of RESTRICTED_ENV_KEYS) { delete env[key] @@ -81,12 +80,7 @@ export function createValidationEnv(inheritedEnv, layout, options = {}) { NODE_ENV: 'development', ORCA_E2E_HOME_DIR: layout.homeDir, ORCA_E2E_USER_DATA_DIR: layout.userDataDir, - ORCA_USER_DATA_PATH: layout.userDataDir, - // Why: flag OFF pins every codex spawn to an explicit managed CODEX_HOME, - // so native codex never resolves the OS profile — the only Windows - // configuration where strict zero-event containment is reachable. It also - // exercises the emergency kill-switch lane users fall back to. - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: options.systemDefaultRealHome === 'off' ? '0' : '1' + ORCA_USER_DATA_PATH: layout.userDataDir } } @@ -268,8 +262,7 @@ function parseArgs(argv) { primaryHome: os.homedir(), configTemplate: null, tempParent: null, - laneAwareContainment: false, - systemDefaultRealHome: 'on' + laneAwareContainment: false } for (let index = 0; index < argv.length; index += 1) { const arg = argv[index] @@ -299,14 +292,8 @@ function parseArgs(argv) { options.skipBuild = true } else if (arg === '--keep') { options.keep = true - } else if (arg === '--system-default-real-home') { - const value = readValue() - if (value !== 'on' && value !== 'off') { - throw new Error('--system-default-real-home must be "on" or "off"') - } - options.systemDefaultRealHome = value } else if (arg === '--lane-aware-containment') { - // Why: on Windows the flag-ON system-default lane cannot be env-sandboxed + // Why: on Windows the system-default real-home lane cannot be env-sandboxed // (native codex ignores USERPROFILE), so strict zero-event containment is // structurally unreachable there. This mode records codex's designed // volatile churn without aborting while every other real-home write stays @@ -314,7 +301,7 @@ function parseArgs(argv) { options.laneAwareContainment = true } else if (arg === '--help') { console.log( - 'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template ] [--temp-parent ] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--system-default-real-home on|off] [--report ]' + 'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template ] [--temp-parent ] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--report ]' ) process.exit(0) } else { @@ -480,7 +467,7 @@ async function main() { const reportPath = options.reportPath ?? path.join(os.tmpdir(), `orca-codex-real-account-${options.scenario}-${Date.now()}.json`) - const launchEnv = createValidationEnv(process.env, layout, options) + const launchEnv = createValidationEnv(process.env, layout) let app = null let tripwire = null const abortController = new AbortController() diff --git a/config/scripts/run-codex-real-account-validation.test.ts b/config/scripts/run-codex-real-account-validation.test.ts index 0b10dee6ee8..43b94983749 100644 --- a/config/scripts/run-codex-real-account-validation.test.ts +++ b/config/scripts/run-codex-real-account-validation.test.ts @@ -46,29 +46,9 @@ describe('Codex real-account validation harness', () => { expect(env.CODEX_HOME).toBeUndefined() expect(env.ORCA_CODEX_HOME).toBeUndefined() expect(env.ZDOTDIR).toBeUndefined() - expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1') expect(env.SAFE_VALUE).toBe('preserved') }) - it('pins the real-home flag off when the system-default lane is disabled', async () => { - const primaryHome = path.join(os.tmpdir(), 'orca-primary-home-sentinel') - const { layout, env } = runValidationModule<{ - layout: { tempRoot: string } - env: Record - }>( - ` - const { createValidationEnv, createValidationLayout } = await import(process.argv[1]) - const layout = await createValidationLayout({ primaryHome: process.argv[2] }) - const env = createValidationEnv({}, layout, { systemDefaultRealHome: 'off' }) - console.log(JSON.stringify({ layout, env })) - `, - [primaryHome] - ) - cleanupPaths.push(layout.tempRoot) - - expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('0') - }) - it('records only fingerprints for system-default and managed auth', async () => { const { layout, snapshot } = runValidationModule<{ layout: { tempRoot: string } diff --git a/config/scripts/run-idle-cpu-benchmark.mjs b/config/scripts/run-idle-cpu-benchmark.mjs index baa061b3590..53cabd3380b 100644 --- a/config/scripts/run-idle-cpu-benchmark.mjs +++ b/config/scripts/run-idle-cpu-benchmark.mjs @@ -483,7 +483,6 @@ async function main() { HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) } }) diff --git a/config/scripts/serve-headless-fresh-profile-pairing.mjs b/config/scripts/serve-headless-fresh-profile-pairing.mjs index 8865b841541..1cbfe92473a 100755 --- a/config/scripts/serve-headless-fresh-profile-pairing.mjs +++ b/config/scripts/serve-headless-fresh-profile-pairing.mjs @@ -55,7 +55,6 @@ Object.assign(childEnv, { ORCA_DEV_USER_DATA_PATH: profileDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' } : {}) diff --git a/config/scripts/verify-linux-wayland-gpu-sandbox.mjs b/config/scripts/verify-linux-wayland-gpu-sandbox.mjs index ef35b6f7f3d..a36f81db86b 100644 --- a/config/scripts/verify-linux-wayland-gpu-sandbox.mjs +++ b/config/scripts/verify-linux-wayland-gpu-sandbox.mjs @@ -219,7 +219,6 @@ async function runValidation(mode) { ORCA_DEV_USER_DATA_PATH: userDataPath, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ELECTRON_ENABLE_LOGGING: '1', ELECTRON_ENABLE_STACK_DUMPING: '1', ELECTRON_OZONE_PLATFORM_HINT: 'wayland', diff --git a/config/scripts/windows-apphang-repro/electron-dev-session.mjs b/config/scripts/windows-apphang-repro/electron-dev-session.mjs index 0aaead7237e..be09b97014b 100644 --- a/config/scripts/windows-apphang-repro/electron-dev-session.mjs +++ b/config/scripts/windows-apphang-repro/electron-dev-session.mjs @@ -63,7 +63,6 @@ export function launchDevApp({ cdpPort, userDataDir }) { ORCA_DEV_USER_DATA_PATH: userDataDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ORCA_SKIP_DEV_WEB_PREPARE: '1', ORCA_STARTUP_DIAGNOSTICS: '1', REMOTE_DEBUGGING_PORT: String(cdpPort), diff --git a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts index 19bc71dac23..d137a65632e 100644 --- a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts +++ b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts @@ -20,12 +20,7 @@ beforeEach(() => { testState.home = mkdtempSync(join(tmpdir(), 'orca-codex-status-home-')) // Why: the real-home check consults CODEX_HOME and the shell rc, so a // developer who exports one would otherwise fail this suite locally. - for (const key of [ - 'ORCA_USER_DATA_PATH', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', - 'CODEX_HOME', - 'ORCA_CODEX_HOME' - ]) { + for (const key of ['ORCA_USER_DATA_PATH', 'CODEX_HOME', 'ORCA_CODEX_HOME']) { previousEnv[key] = process.env[key] delete process.env[key] } @@ -91,8 +86,8 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => { expect(service.getMirroredHostHomePathForStatus()).toBe(account.managedHomePath) }) - it('returns the shared runtime home when the real-home lane is off', async () => { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = '0' + it('returns the shared runtime home when a custom CODEX_HOME keeps the mirror lane', async () => { + process.env.CODEX_HOME = join(testState.home, 'custom-codex-home') const { CodexRuntimeHomeService } = await import('./runtime-home-service') const { getOrcaManagedCodexHomePath } = await import('../codex/codex-home-paths') const service = new CodexRuntimeHomeService(createStore([], null) as never) diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 4012cf3b10c..0efe90bb77c 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -21,7 +21,6 @@ beforeEach(() => { testState.home = mkdtempSync(join(tmpdir(), 'orca-codex-e-home-')) for (const key of [ 'ORCA_USER_DATA_PATH', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_DISABLE_CODEX_TRUST_RPC', 'CODEX_HOME', 'ORCA_CODEX_HOME' diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 693ac8db7cf..8d3a52fa823 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -17,13 +17,13 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' -import type * as NodeFs from 'node:fs' import type * as ShellStartupEnv from '../pty/shell-startup-env' const testState = { userDataDir: '', fakeHomeDir: '', - previousUserDataPath: undefined as string | undefined + previousUserDataPath: undefined as string | undefined, + shellStartupEnvProbeSupported: true } vi.mock('electron', () => ({ @@ -40,34 +40,24 @@ vi.mock('node:os', async () => { } }) -// Why: the shipped code no longer reads a settings flag — the legacy mirror -// lane is reachable only through the test-rig env override. Route the old -// per-test override key to that env var so this suite's lane coverage and -// mid-test flips keep exercising real transitions. +// Why: the shared system-default mirror is still live wherever the shell-startup +// probe is unavailable (Windows), so drive this suite's lane coverage and +// mid-test flips through that real gate rather than a test-only override. type TestSettingsOverrides = Partial & { - codexSystemDefaultRealHomeEnabled?: boolean + shellStartupEnvProbeSupported?: boolean } -function setRealHomeLaneForTest(enabled: boolean): void { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = enabled ? '1' : '0' +function setShellStartupEnvProbeSupportedForTest(enabled: boolean): void { + testState.shellStartupEnvProbeSupported = enabled } -const initialRealHomeLaneEnv = process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME -afterEach(() => { - if (initialRealHomeLaneEnv === undefined) { - delete process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME - } else { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = initialRealHomeLaneEnv - } -}) - function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false - // Managed-home tests assert the mirror path; production is real-home always, - // so opt these managed cases out unless a test overrides it. - setRealHomeLaneForTest(overrides.codexSystemDefaultRealHomeEnabled ?? false) + // Mirror-path tests assert the shared runtime home, which production still uses + // on Windows; opt these cases onto that lane unless a test overrides it. + setShellStartupEnvProbeSupportedForTest(overrides.shellStartupEnvProbeSupported ?? false) return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -320,9 +310,10 @@ describe('CodexRuntimeHomeService', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + testState.shellStartupEnvProbeSupported = true vi.doMock('../pty/shell-startup-env', async () => ({ ...(await vi.importActual('../pty/shell-startup-env')), - isShellStartupEnvProbeSupported: () => true + isShellStartupEnvProbeSupported: () => testState.shellStartupEnvProbeSupported })) testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-runtime-home-')) testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) @@ -367,503 +358,6 @@ describe('CodexRuntimeHomeService', () => { } }) - it('materializes the active managed account auth into the runtime home on startup', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') - expect( - existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) - ).toBe(false) - }) - - it('deselects a shared-home account on settled credential-free JSON', async () => { - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-user', - workspaceLabel: null, - workspaceAccountId: 'acct-user', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - store.updateSettings.mockClear() - - writeFileSync(join(managedHomePath, 'auth.json'), '{}\n', 'utf-8') - service.syncForCurrentSelection() - - expect(store.getSettings().activeCodexManagedAccountId).toBeNull() - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull() - expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe('{}\n') - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) - expect(warnSpy).toHaveBeenCalledWith( - '[codex-runtime-home] Active managed account credential is unavailable, restoring system default' - ) - }) - - it('deselects a shared-home account when unreadable auth outlives the grace window', async () => { - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-user', - workspaceLabel: null, - workspaceAccountId: 'acct-user', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - store.updateSettings.mockClear() - writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') - const observedAt = Date.now() - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(observedAt) - - service.syncForCurrentSelection() - expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') - expect(store.updateSettings).not.toHaveBeenCalled() - - nowSpy.mockReturnValue(observedAt + 6_000) - service.syncForCurrentSelection() - nowSpy.mockRestore() - expect(store.getSettings().activeCodexManagedAccountId).toBeNull() - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull() - }) - - it.each([ - ['missing', (authPath: string) => rmSync(authPath, { force: true })], - [ - 'unreadable', - (authPath: string) => { - chmodSync(authPath, 0o000) - } - ] - ])( - 'never launches the previously synced account when the selected account auth is %s', - async (label, breakAuth) => { - if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) { - return - } - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const authB = createCodexAuthJson('b@example.com', 'acct-b', 'b') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth(testState.userDataDir, 'account-b', authB) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-a', - email: 'a@example.com', - managedHomePath: homeA, - providerAccountId: 'acct-a', - workspaceLabel: null, - workspaceAccountId: 'acct-a', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-b', - email: 'b@example.com', - managedHomePath: homeB, - providerAccountId: 'acct-b', - workspaceLabel: null, - workspaceAccountId: 'acct-b', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-a', - activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} } - }) - ) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(authA) - - store.updateSettings({ - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - breakAuth(join(homeB, 'auth.json')) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - - // The selection survives the grace window, but the launch must never run - // as account A while the UI says account B. - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( - authA - ) - expect(warnSpy).toHaveBeenCalledWith( - '[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth' - ) - } - ) - - it.each([ - ['missing', (authPath: string) => rmSync(authPath, { force: true })], - [ - 'unreadable', - (authPath: string) => { - chmodSync(authPath, 0o000) - } - ] - ])( - 'preserves the mirrored refresh of the account the runtime home actually holds when the selected account auth is %s', - async (label, breakAuth) => { - if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) { - return - } - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a', 1) - const authARefreshed = createCodexAuthJson('a@example.com', 'acct-a', 'a-refreshed', 2) - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // A crash left account A's refreshed tokens in the mirror while settings - // already select account B, whose own auth.json cannot be read. - writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, - 'utf-8' - ) - breakAuth(join(homeB, 'auth.json')) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-a', - email: 'a@example.com', - managedHomePath: homeA, - providerAccountId: 'acct-a', - workspaceLabel: null, - workspaceAccountId: 'acct-a', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-b', - email: 'b@example.com', - managedHomePath: homeB, - providerAccountId: 'acct-b', - workspaceLabel: null, - workspaceAccountId: 'acct-b', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - } - ) - - it('keeps the runtime auth through the grace window while the selected account rotates', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-user', - workspaceLabel: null, - workspaceAccountId: 'acct-user', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - - // Codex rewrites the selected account's auth.json in place: the runtime home - // already holds that same account, so its credentials must survive the race. - writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') - }) - - it('keeps runtime auth the selected account still owns when provenance is unreadable', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - // Cold start: the mirror holds the selected account's own credential, but a - // torn provenance file can no longer say so and its auth.json reads torn. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, managedAuth, 'utf-8') - writeFileSync(getSharedRuntimeAuthProvenancePath(), 'not-json', 'utf-8') - writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-1', 'user@example.com', 'acct-user', managedHomePath) - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') - }) - - it.each([ - ['no provenance', (path: string) => rmSync(path, { force: true })], - [ - 'selected-account provenance', - (path: string) => - writeFileSync( - path, - `${JSON.stringify({ owner: 'managed', accountId: 'account-b' })}\n`, - 'utf-8' - ) - ] - ])('never launches another account on a cold start with %s', async (_label, setProvenance) => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // A retained pane can overwrite the mirror after B's provenance commits. - // Neither missing nor stale provenance can overrule the current bytes. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, authA, 'utf-8') - setProvenance(getSharedRuntimeAuthProvenancePath()) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( - authA - ) - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authA) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - }) - - it('persists a mirror-only system-default refresh before dropping unproven runtime auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system', 1) - const systemAuthRefreshed = createCodexAuthJson( - 'system@example.com', - 'acct-system', - 'system-refreshed', - 2 - ) - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // Codex rotated the system-default credential inside the mirror; ~/.codex is - // still the older copy Orca mirrored, so the refresh lives only here. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, systemAuthRefreshed, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'system-default', authJson: systemAuth })}\n`, - 'utf-8' - ) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuthRefreshed) - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - - it('blocks launch when a readable stale runtime auth cannot be deleted', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // A Windows sharing violation can deny delete while still allowing Codex to - // read the credential, so model those operations independently. - writeFileSync(runtimeAuthPath, authA, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, - 'utf-8' - ) - rmSync(join(homeB, 'auth.json'), { force: true }) - vi.doMock('node:fs', async () => { - const actual = await vi.importActual('node:fs') - return { - ...actual, - rmSync: (path: Parameters[0], options?: Parameters[1]) => { - if (path === runtimeAuthPath) { - throw new Error('simulated Windows sharing violation') - } - return actual.rmSync(path, options) - } - } - }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(() => service.prepareForCodexLaunch()).toThrow( - 'Cannot safely launch Codex while stale runtime auth remains.' - ) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(authA) - expect(JSON.parse(readFileSync(getSharedRuntimeAuthProvenancePath(), 'utf-8'))).toEqual({ - owner: 'fenced' - }) - }) - it('refuses to read runtime auth back into a duplicate account while a home is unreadable', async () => { if (process.platform === 'win32' || process.getuid?.() === 0) { return @@ -911,153 +405,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(join(homeX2, 'auth.json'), 'utf-8')).toBe(authX) }) - it('drops another account from the mirror even when the fence write fails', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // Stands in for a Windows AV lock on the provenance file: the fence cannot - // be written, while auth.json itself is perfectly deletable. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, authA, 'utf-8') - rmSync(getSharedRuntimeAuthProvenancePath(), { force: true }) - mkdirSync(getSharedRuntimeAuthProvenancePath(), { recursive: true }) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( - authA - ) - }) - - it('keeps an unattributable api-key credential Orca mirrored in the same run', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const apiKeyAuth = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-managed' })}\n` - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', apiKeyAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-1', 'user@example.com', 'acct-user', managedHomePath) - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Exact same-run bytes prove ownership even without identity claims. - writeFileSync(join(managedHomePath, 'auth.json'), '{"OPENAI_API_K', 'utf-8') - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(apiKeyAuth) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') - }) - - it('drops a pre-provenance api-key credential when the selected owner cannot be proven', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-a' })}\n` - const authB = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-b' })}\n` - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth(testState.userDataDir, 'account-b', authB) - writeFileSync(runtimeAuthPath, authA, 'utf-8') - rmSync(getSharedRuntimeAuthProvenancePath(), { force: true }) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authA) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - }) - - it('reads a renamed account refresh back into its own home despite a stale record email', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('new@example.com', 'acct-a', 'a', 1) - const authARefreshed = createCodexAuthJson('new@example.com', 'acct-a', 'a-refreshed', 2) - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // Account A was renamed on the ChatGPT side after it was added, so its record - // email is stale while the mirrored refresh carries the new one. - writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, - 'utf-8' - ) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'old@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed) - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - it('keeps the mirror of a renamed account whose record email is stale', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync( @@ -1108,29 +455,7 @@ describe('CodexRuntimeHomeService', () => { process.platform === 'win32' ? 'junction' : undefined ) writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) @@ -1139,7 +464,7 @@ describe('CodexRuntimeHomeService', () => { normalizeLinkTarget(getRuntimeCodexHomePath()) ) expect(readFileSync(join(legacyActiveHomePath, 'auth.json'), 'utf-8')).toBe( - '{"account":"managed"}\n' + '{"account":"system"}\n' ) }) @@ -1540,42 +865,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') }) - it('removes runtime auth when restoring a no-login system default', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') - - settings.activeCodexManagedAccountId = null - service.syncForCurrentSelection() - - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - it('removes runtime auth when deselecting with a missing system-default snapshot', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') @@ -1652,9 +941,7 @@ describe('CodexRuntimeHomeService', () => { }) }) - it('clears an invalid active account selection and removes untrusted runtime auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + it('clears an active account selection whose self-contained home is missing', async () => { const missingManagedHomePath = join( testState.userDataDir, 'codex-accounts', @@ -1686,47 +973,6 @@ describe('CodexRuntimeHomeService', () => { expect(store.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ activeCodexManagedAccountId: null }) ) - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(warnSpy).toHaveBeenCalled() - }) - - it('clears an invalid active account selection and restores live system default auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = '{"account":"system"}\n' - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - rmSync(runtimeAuthPath, { force: true }) - const missingManagedHomePath = join( - testState.userDataDir, - 'codex-accounts', - 'account-1', - 'home' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath: missingManagedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(store.updateSettings).toHaveBeenCalledWith( - expect.objectContaining({ activeCodexManagedAccountId: null }) - ) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) expect(warnSpy).toHaveBeenCalled() }) @@ -1760,14 +1006,15 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(markerPath)).toBe(false) + // Why: a mirror launch never leaves the real home, so its backfill stays valid. + expect(existsSync(markerPath)).toBe(true) expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) }) - it('routes host system default to the real home when the flag is ON', async () => { - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + it('routes host system default to the real home', async () => { + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -1843,7 +1090,7 @@ describe('CodexRuntimeHomeService', () => { it('seeds shared auth for a pane-local custom home on the real-home lane', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const customHome = join(testState.fakeHomeDir, 'pane-custom-codex-home') @@ -1878,7 +1125,7 @@ describe('CodexRuntimeHomeService', () => { `${JSON.stringify({ owner: 'system-default', authJson: retainedAuth })}\n`, 'utf-8' ) - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -1909,12 +1156,12 @@ describe('CodexRuntimeHomeService', () => { '' ].join('\n') writeFileSync(getSystemCodexAuthPath(), oldSystemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(oldSystemAuth) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') writeFileSync(join(getSystemCodexHomePath(), 'config.toml'), systemConfig, 'utf-8') writeFileSync( @@ -1940,12 +1187,12 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) const restartedService = new CodexRuntimeHomeService(store as never) restartedService.setRealHomeLaneGate(() => true) @@ -1954,43 +1201,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) }) - it('preserves same-identity retained managed auth after restart and source logout', async () => { - const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') - const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'shared@example.com', - managedHomePath, - providerAccountId: 'acct-shared', - workspaceLabel: null, - workspaceAccountId: 'acct-shared', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ] - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - settings.activeCodexManagedAccountId = 'account-1' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } - service.syncForCurrentSelection() - - settings.activeCodexManagedAccountId = null - settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } - setRealHomeLaneForTest(true) - rmSync(getSystemCodexAuthPath()) - const restartedService = new CodexRuntimeHomeService(store as never) - - expect(restartedService.prepareForCodexLaunch()).toBeNull() - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) - }) - it('fences retained shared auth when a self-contained managed transition begins', async () => { const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') @@ -2015,7 +1225,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) settings.activeCodexManagedAccountId = 'account-1' settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } service.syncForCurrentSelection() @@ -2062,7 +1272,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) settings.activeCodexManagedAccountId = 'account-1' settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } service.syncForCurrentSelection() @@ -2100,14 +1310,14 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) settings.activeCodexManagedAccountId = 'account-1' settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } service.syncForCurrentSelection() writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') settings.activeCodexManagedAccountId = null settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } - setRealHomeLaneForTest(false) + setShellStartupEnvProbeSupportedForTest(false) service.syncForCurrentSelection() expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) @@ -2121,12 +1331,12 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) @@ -2136,13 +1346,13 @@ describe('CodexRuntimeHomeService', () => { it('does not rewrite retained-auth provenance during unchanged rate polling', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const provenancePath = getSharedRuntimeAuthProvenancePath() const originalInode = statSync(provenancePath).ino - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.prepareForRateLimitFetch() service.prepareForRateLimitFetch() @@ -2152,10 +1362,10 @@ describe('CodexRuntimeHomeService', () => { it('does not rewrite completed retained logout metadata during rate polling', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) service.prepareForRateLimitFetch() const metadataPaths = [ @@ -2213,7 +1423,7 @@ describe('CodexRuntimeHomeService', () => { ) settings.activeCodexManagedAccountId = null settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.syncForCurrentSelection() writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') @@ -2232,7 +1442,7 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') @@ -2247,11 +1457,11 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) const service = new CodexRuntimeHomeService(store as never) writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') rmSync(getSystemCodexAuthPath()) @@ -2268,7 +1478,7 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync( @@ -2280,7 +1490,7 @@ describe('CodexRuntimeHomeService', () => { })}\n` ) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) @@ -2289,14 +1499,14 @@ describe('CodexRuntimeHomeService', () => { it('recovers runtime auth quarantined by an interrupted guarded update', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) const runtimeAuthPath = getRuntimeCodexAuthPath() const heldAuthPath = `${runtimeAuthPath}.orca-guarded` renameSync(runtimeAuthPath, heldAuthPath) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) new CodexRuntimeHomeService(store as never) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) @@ -2307,7 +1517,7 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') const retainedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'retained') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync( @@ -2320,7 +1530,7 @@ describe('CodexRuntimeHomeService', () => { ) writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) @@ -2334,12 +1544,12 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync(getSharedRuntimeAuthProvenancePath(), '{"owner":"pending"}\n') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) @@ -2349,11 +1559,11 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) const service = new CodexRuntimeHomeService(store as never) rmSync(getSystemCodexAuthPath()) expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) @@ -2373,11 +1583,11 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) rmSync(getRuntimeCodexAuthPath()) if (removeProvenance) { @@ -2408,7 +1618,7 @@ describe('CodexRuntimeHomeService', () => { createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') ) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: false, + shellStartupEnvProbeSupported: false, codexManagedAccounts: [ { id: 'account-1', @@ -2428,7 +1638,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) rmSync(getRuntimeCodexAuthPath()) rmSync( @@ -2458,14 +1668,14 @@ describe('CodexRuntimeHomeService', () => { const systemConfigPath = join(getSystemCodexHomePath(), 'config.toml') const runtimeConfigPath = join(getRuntimeCodexHomePath(), 'config.toml') writeFileSync(systemConfigPath, 'model = "baseline"\n', 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) writeFileSync(runtimeConfigPath, 'model = "runtime-change"\n', 'utf-8') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.setRealHomeLaneGate(() => false) service.reconcileLegacySharedHomeForRetainedPanes() expect(readFileSync(systemConfigPath, 'utf-8')).toBe('model = "baseline"\n') @@ -2476,7 +1686,7 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') }) - it('routes a host MANAGED account to its own self-contained home when the flag is ON', async () => { + it('routes a host MANAGED account to its own self-contained home', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const runtimeAuthPath = getRuntimeCodexAuthPath() const managedHomePath = createManagedAuth( @@ -2486,7 +1696,7 @@ describe('CodexRuntimeHomeService', () => { ) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2507,7 +1717,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Flag ON + host managed account = the account's own home is CODEX_HOME. + // A host managed account's own home is its CODEX_HOME. expect(service.isHostSystemDefaultRealHome()).toBe(false) expect(service.prepareForCodexLaunch()).toBe(managedHomePath) // The per-account home keeps its own auth in place; the shared mirror's @@ -2520,14 +1730,14 @@ describe('CodexRuntimeHomeService', () => { expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(managedHomePath) }) - it('gives two managed accounts distinct homes without racing one auth.json (flag ON)', async () => { + it('gives two managed accounts distinct homes without racing one auth.json', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) const home2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2579,7 +1789,7 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) }) - it('materializes resources and config into the per-account home on launch (flag ON)', async () => { + it('materializes resources and config into the per-account home on launch', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true }) writeFileSync( @@ -2599,7 +1809,7 @@ describe('CodexRuntimeHomeService', () => { ) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2629,11 +1839,11 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe('{"account":"system"}\n') }) - it('points the rate-limit fetch at the per-account home when the flag is ON', async () => { + it('points the rate-limit fetch at the per-account home', async () => { const home1 = createManagedAuth(testState.userDataDir, 'account-1', '{"account":"managed"}\n') const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2657,14 +1867,14 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch()).toBe(home1) }) - it('preserves a managed selection whose auth.json is temporarily missing (flag ON)', async () => { + it('preserves a managed selection whose auth.json is temporarily missing', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') // A managed home that has lost its auth.json (only the marker remains). const brokenHome = join(testState.userDataDir, 'codex-accounts', 'account-1', 'home') mkdirSync(brokenHome, { recursive: true }) writeFileSync(join(brokenHome, '.orca-managed-home'), 'account-1\n', 'utf-8') const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2690,41 +1900,9 @@ describe('CodexRuntimeHomeService', () => { expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') }) - it('keeps the shared runtime home + auth hot-swap for managed accounts when the flag is OFF', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') - const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const store = createStore( - createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Flag OFF is byte-identical to today: shared mirror home + hot-swapped auth. - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1Auth) - }) - - it('keeps pre-E shared-mirror sessions discoverable alongside per-account rollouts (flag ON)', async () => { + it('keeps pre-E shared-mirror sessions discoverable alongside per-account rollouts', async () => { // Pre-E history lives in the shared runtime mirror; after upgrading to - // per-account homes (flag ON) new rollouts land in the account's own home. + // per-account homes, new rollouts land in the account's own home. const sharedSessionsDir = join(getRuntimeCodexHomePath(), 'sessions', '2026', '07', '16') mkdirSync(sharedSessionsDir, { recursive: true }) writeFileSync(join(sharedSessionsDir, 'rollout-pre-e.jsonl'), '{"record":"pre-e"}\n', 'utf-8') @@ -2742,7 +1920,7 @@ describe('CodexRuntimeHomeService', () => { ) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2770,9 +1948,9 @@ describe('CodexRuntimeHomeService', () => { expect(discovery).toContain(home1) }) - it('surfaces per-account rollouts for session discovery after opting out (flag OFF)', async () => { - // The account ran with the flag ON and accumulated rollouts in its own home, - // then the user opted back out (flag OFF, byte-identical launch path). + it('surfaces per-account rollouts for session discovery on the mirror lane', async () => { + // A Windows host keeps the shared system-default mirror, but its managed + // accounts still launch from their own homes and accumulate rollouts there. const home1 = createManagedAuth( testState.userDataDir, 'account-1', @@ -2783,7 +1961,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(join(rolloutDir, 'rollout-e-era.jsonl'), '{"record":"e-era"}\n', 'utf-8') const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: false, + shellStartupEnvProbeSupported: false, codexManagedAccounts: [ { id: 'account-1', @@ -2804,118 +1982,10 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Opting out must not hide rollouts that already live in the per-account home. + // The mirror lane must not hide rollouts living in the per-account home. expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(home1) }) - it('does not scan an opted-out never-enabled account home that holds no rollouts', async () => { - // A pure never-enabled install keeps its per-account homes credential-only, so - // opt-out discovery must stay byte-identical to today (mirror home only). - const home1 = createManagedAuth( - testState.userDataDir, - 'account-1', - createCodexAuthJson('one@example.com', 'acct-1', 'one') - ) - const store = createStore( - createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.getHostCodexHomePathsForSessionDiscovery()).not.toContain(home1) - }) - - it('preserves a fresh per-account credential and propagates it to the shared mirror on opt-out', async () => { - // Flag ON refreshed the account's token in its own home; the shared mirror's - // auth is frozen from before the ON period. Opting out (flag OFF) must not let - // the stale mirror token clobber the fresher per-account credential. - const freshAuth = createCodexAuthJson('one@example.com', 'acct-1', 'fresh', 4_000) - const staleAuth = createCodexAuthJson('one@example.com', 'acct-1', 'stale', 1_000) - const home1 = createManagedAuth(testState.userDataDir, 'account-1', freshAuth) - // A stale mirror auth left over from before the account ran with the flag ON. - writeFileSync(getRuntimeCodexAuthPath(), staleAuth, 'utf-8') - const store = createStore( - createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - // Constructor runs the startup opt-out sync (read-back + hot-swap). - new CodexRuntimeHomeService(store as never) - - // The fresher per-account credential is preserved (never overwritten by the - // stale mirror read-back) and becomes the mirror's auth for the opted-out launch. - expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(freshAuth) - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(freshAuth) - }) - - it('rolls a live self-contained account back without adopting newer shared bytes', async () => { - const accountAuth = createCodexAuthJson('one@example.com', 'acct-1', 'in-place', 2_000) - const laterShared = createCodexAuthJson('one@example.com', 'acct-1', 'shared', 3_000) - const home1 = createManagedAuth(testState.userDataDir, 'account-1', accountAuth) - const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch()).toBe(home1) - writeFileSync(getRuntimeCodexAuthPath(), laterShared, 'utf-8') - - setRealHomeLaneForTest(false) - service.syncForCurrentSelection() - - expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(accountAuth) - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(accountAuth) - }) - it('keeps per-account auth canonical when the real-home lane takes over', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') @@ -2929,7 +1999,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2950,7 +2020,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Flag ON launches the account from its own home without populating the + // Launching the account from its own home never populates the // legacy shared mirror. expect(service.isHostSystemDefaultRealHome()).toBe(false) expect(service.prepareForCodexLaunch()).toBe(managedHomePath) @@ -2983,7 +2053,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3031,7 +2101,7 @@ describe('CodexRuntimeHomeService', () => { createCodexAuthJson('wsl@example.com', 'acct-wsl', 'wsl') ) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3138,7 +2208,7 @@ describe('CodexRuntimeHomeService', () => { const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', accountAuth) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3199,7 +2269,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3245,7 +2315,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3281,328 +2351,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) }) - it('uses the same host CODEX_HOME after switching managed Codex accounts', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - const account1Home = service.prepareForCodexLaunch() - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - const account2Home = service.prepareForCodexLaunch() - - expect(account1Home).toBe(getRuntimeCodexHomePath()) - expect(account2Home).toBe(getRuntimeCodexHomePath()) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('new host Codex launches get the selected account after preserving outgoing refreshes', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-1', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('reads back selected-account refreshes without ambiguity from duplicate identities', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'same@example.com', - 'acct-same', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) - }) - - it('keeps fresher selected-account startup refreshes when duplicate identities exist', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'same@example.com', - 'acct-same', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) - }) - - it('routes stale live-pane startup refreshes to the matching account before restoring selected auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-1', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-2', - activeCodexManagedAccountIdsByRuntime: { host: 'account-2', wsl: {} } - }) - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('preserves duplicate-identity outgoing refreshes before switching to another account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'same@example.com', - 'acct-same', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) - const account3Auth = createCodexAuthJson('three@example.com', 'acct-3', 'three', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedHomePath3 = createManagedAuth(testState.userDataDir, 'account-3', account3Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - }, - { - id: 'account-3', - email: 'three@example.com', - managedHomePath: managedHomePath3, - providerAccountId: 'acct-3', - workspaceLabel: null, - workspaceAccountId: 'acct-3', - createdAt: 3, - updatedAt: 3, - lastAuthenticatedAt: 3 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - settings.activeCodexManagedAccountId = 'account-3' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-3', wsl: {} } - service.syncForCurrentSelection() - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account3Auth) - }) - it('mirrors later system Codex config changes before launch', async () => { const systemCodexHome = getSystemCodexHomePath() mkdirSync(systemCodexHome, { recursive: true }) @@ -3632,7 +2380,7 @@ describe('CodexRuntimeHomeService', () => { '' ].join('\n') writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -4129,7 +2877,6 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(hostAuth) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( wslRuntimeHomePath ) @@ -4935,38 +3682,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) }) - it('removes untrusted runtime auth on restart when persisted active account is invalid', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath: join(testState.userDataDir, 'codex-accounts', 'account-1', 'home'), - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(store.updateSettings).toHaveBeenCalledWith( - expect.objectContaining({ activeCodexManagedAccountId: null }) - ) - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(warnSpy).toHaveBeenCalled() - }) - it('imports legacy managed-home history into the shared runtime history', async () => { const runtimeHomePath = getRuntimeCodexHomePath() const runtimeHistoryPath = join(runtimeHomePath, 'history.jsonl') @@ -5000,30 +3715,8 @@ describe('CodexRuntimeHomeService', () => { } const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) @@ -5038,29 +3731,8 @@ describe('CodexRuntimeHomeService', () => { } const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -5143,385 +3815,6 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(snapshotPath)).toBe(false) }) - it('reads back CLI-refreshed tokens into managed storage on subsequent sync', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') - const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Simulate CLI refreshing the token in runtime CODEX_HOME/auth.json. - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - - // Next sync should read back the refreshed token to managed storage - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) - }) - - it('rejects runtime read-back from a different Codex identity', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') - const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'selected@example.com', - managedHomePath, - providerAccountId: 'acct-selected', - workspaceLabel: null, - workspaceAccountId: 'acct-selected', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Simulate an old live Codex PTY from another account refreshing the - // shared runtime auth after Orca has already selected account-1. - writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) - }) - - it('routes runtime read-back from a different Codex identity to its matching account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-one', - 'one-refreshed' - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-2' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // An older account-1 Codex process refreshed the shared runtime file after - // Orca selected account-2. Persist the refresh to account-1, then restore - // the selected account in runtime CODEX_HOME. - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('rejects ambiguous Codex read-back instead of choosing a managed account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('same@example.com', 'acct-same', 'original') - const refreshedAuth = createCodexAuthJson('same@example.com', 'acct-same', 'refreshed') - const activeAuth = createCodexAuthJson('active@example.com', 'acct-active', 'active') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', originalAuth) - const managedHomePath3 = createManagedAuth(testState.userDataDir, 'account-3', activeAuth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - }, - { - id: 'account-3', - email: 'active@example.com', - managedHomePath: managedHomePath3, - providerAccountId: 'acct-active', - workspaceLabel: null, - workspaceAccountId: 'acct-active', - createdAt: 3, - updatedAt: 3, - lastAuthenticatedAt: 3 - } - ], - activeCodexManagedAccountId: 'account-3' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(originalAuth) - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(originalAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(activeAuth) - }) - - it('rejects runtime read-back without a positive selected-account identity match', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') - const accountOnlyAuth = `${JSON.stringify({ - tokens: { - account_id: 'acct-stale', - refresh_token: 'stale' - } - })}\n` - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'selected@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, accountOnlyAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) - }) - - it('rejects same-email runtime read-back when account ids differ from sparse managed metadata', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const selectedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'selected') - const staleLivePtyAuth = createCodexAuthJson('user@example.com', 'acct-stale', 'stale') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) - }) - - it('reads back same-account refreshes for sparse managed metadata using stored auth identity', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'original') - const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'refreshed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) - }) - - it('reads back strong account-id refreshes when the runtime auth has no email', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') - const refreshedAuth = `${JSON.stringify({ - tokens: { - account_id: 'acct-1', - refresh_token: 'refreshed' - } - })}\n` - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) - }) - - it('rejects unverifiable Codex read-back on first sync after restart', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"credential":"refreshed-while-down"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"credential":"original"}\n' - ) - const managedAuthPath = join(managedHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"credential":"original"}\n') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"credential":"original"}\n') - }) - it('reads back verified same-account refreshes on first sync after restart', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original', 1_000) @@ -5555,201 +3848,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) }) - it('rejects older same-account Codex auth on first sync after restart', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const staleRuntimeAuth = createCodexAuthJson('user@example.com', 'acct-1', 'stale', 1_000) - const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed-newer', 2_000) - writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(managedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - }) - - it('does not contaminate the incoming Codex account during account switch', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath1 = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"credential":"account1"}\n' - ) - const managedHomePath2 = createManagedAuth( - testState.userDataDir, - 'account-2', - '{"credential":"account2"}\n' - ) - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user1@example.com', - managedHomePath: managedHomePath1, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'user2@example.com', - managedHomePath: managedHomePath2, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - settings.activeCodexManagedAccountId = 'account-2' - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe('{"credential":"account2"}\n') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"credential":"account2"}\n') - }) - - it('does not carry the reauth read-back skip across Codex account switches', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const account2RefreshedAuth = createCodexAuthJson( - 'two@example.com', - 'acct-two', - 'two-refreshed' - ) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - service.clearLastWrittenAuthJson() - settings.activeCodexManagedAccountId = 'account-2' - service.syncForCurrentSelection() - - writeFileSync(runtimeAuthPath, account2RefreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2RefreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2RefreshedAuth) - }) - - it('does not apply inactive-account Codex reauth skip to the active account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-one', - 'one-refreshed' - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - service.clearLastWrittenAuthJson('account-2') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) - }) - it('restores system default when unverified runtime auth appears before deselect', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') @@ -5795,44 +3893,6 @@ describe('CodexRuntimeHomeService', () => { ).toBe(true) }) - it('restores system default after same-identity managed Codex refresh on deselect', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') - const externalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'external') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ] - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - settings.activeCodexManagedAccountId = 'account-1' - service.syncForCurrentSelection() - - writeFileSync(runtimeAuthPath, externalAuth, 'utf-8') - settings.activeCodexManagedAccountId = null - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(externalAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-old"}\n') - }) - it('restores system default when stale Codex credentials are rejected on deselect', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') @@ -5907,78 +3967,19 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('keeps external system-default logout when managed runtime auth still exists', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') - rmSync(getSystemCodexAuthPath(), { force: true }) - settings.activeCodexManagedAccountId = null - service.syncForCurrentSelection() - - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - - it('recreates retained auth after switching from managed to logged-out system default', async () => { + it('recreates retained auth after a logged-out system default logs back in', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'managed@example.com', - managedHomePath, - providerAccountId: 'acct-managed', - workspaceLabel: null, - workspaceAccountId: 'acct-managed', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) rmSync(getSystemCodexAuthPath()) - settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.setRealHomeLaneGate(() => true) writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') service.reconcileLegacySharedHomeForRetainedPanes() @@ -6027,95 +4028,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-2"}\n') }) - it('reads back refreshed tokens for the outgoing Codex account before switching', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Original = createCodexAuthJson('one@example.com', 'acct-1', 'one-original') - const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Original) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1Refreshed, 'utf-8') - settings.activeCodexManagedAccountId = 'account-2' - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1Refreshed) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') - const reauthedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'reauthed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Simulate re-auth: managed storage gets fresh tokens - writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') - - // Clear tracking before sync (as CodexAccountService would) - service.clearLastWrittenAuthJson() - service.syncForCurrentSelection() - - // Fresh re-auth tokens should survive — not be clobbered by stale runtime read-back - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) - }) - it('preserves conflicting legacy session files under deterministic names', async () => { const runtimeSessionsDir = join(getRuntimeCodexHomePath(), 'sessions') mkdirSync(runtimeSessionsDir, { recursive: true }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index fa2f27798b6..c2b99b52c6d 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -67,7 +67,6 @@ import { type CodexAccountSelectionTarget } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' -import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' @@ -176,8 +175,8 @@ export class CodexRuntimeHomeService { private readonly lastSyncedWslAccountIdByDistro = new Map() private readonly wslRuntimeHomePathByDistro = new Map() private skipNextReadBackForAccountId: string | null = null - // Why: a flag-ON host account refreshes auth in its own home. Remember that - // provenance so a later deselect/rollback never adopts stale shared bytes. + // Why: a managed host account refreshes auth in its own home. Remember that + // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false private sharedAuthRefreshBlockedByManagedTransition = false // Why: transient auth.json read/parse failures must not deselect an account. @@ -236,7 +235,7 @@ export class CodexRuntimeHomeService { // system default without injecting a path Orca cannot prove it owns. } if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why (flag ON, system default): run Codex on the user's own ~/.codex. + // Why: the system default runs Codex on the user's own ~/.codex. // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; // the retired mirror is refreshed only for pre-rollout PTYs. this.reconcileLegacySharedHomeForRetainedPanes() @@ -254,17 +253,12 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } - // Why: with the real-home flag ON, a managed HOST account runs against its own - // self-contained CODEX_HOME (codex-accounts//home) instead of the shared - // runtime mirror. Its auth.json lives there and codex refreshes it in place, - // so two accounts never race one auth.json (GAP-5) and the mirror can be - // deleted once no lane still injects it (GAP-1). WSL accounts keep their - // per-distro lane; the flag-OFF opt-out keeps the shared-home hot-swap. + // Why: a managed HOST account runs against its own self-contained CODEX_HOME + // (codex-accounts//home) rather than the shared runtime mirror. Its + // auth.json lives there and codex refreshes it in place, so two accounts never + // race one auth.json. WSL accounts keep their per-distro lane. private getSelfContainedManagedHostAccount(): CodexManagedAccount | null { const settings = this.store.getSettings() - if (!isCodexSystemDefaultRealHomeEnabled()) { - return null - } const account = this.getActiveAccount( settings.codexManagedAccounts, normalizeCodexRuntimeSelection(settings).host @@ -276,22 +270,17 @@ export class CodexRuntimeHomeService { } // Why: session discovery must surface a managed account's own rollouts wherever - // they physically live. Flag ON makes every host managed home a live CODEX_HOME, - // so scan them all. Flag OFF (opt-out/rollback) hands launches back to the shared - // mirror, but a home that already accumulated rollouts while the flag was ON must - // still surface them — otherwise opting out silently hides history that is safe on - // disk. Gate the flag-OFF case on a sessions/ tree so a never-enabled install stays - // byte-identical to today (its per-account homes hold only auth, no rollouts). + // they physically live. Every host managed home is a live CODEX_HOME, so scan + // them all. private getManagedHostAccountHomesForSessionDiscovery(): string[] { const settings = this.store.getSettings() - const flagEnabled = isCodexSystemDefaultRealHomeEnabled() const homes: string[] = [] for (const account of settings.codexManagedAccounts) { if (this.getWslManagedHomePath(account)) { continue } const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) - if (trustedHome && (flagEnabled || existsSync(join(trustedHome, 'sessions')))) { + if (trustedHome) { homes.push(trustedHome) } } @@ -420,8 +409,9 @@ export class CodexRuntimeHomeService { if (normalizeCodexRuntimeSelection(settings).host !== null) { return } - const realHomeSelected = this.isHostSystemDefaultRealHomeSelected(launchEnv) - if (realHomeSelected || !isCodexSystemDefaultRealHomeEnabled()) { + // Why: reached only when the real-home lane is selected but its gate is off, + // so the launch runs on the mirror and the backfill marker is stale. + if (this.isHostSystemDefaultRealHomeSelected(launchEnv)) { invalidateCodexSessionBackfillMarker( join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json') ) @@ -463,10 +453,9 @@ export class CodexRuntimeHomeService { // mirror, so include the real root for both directly-routed host lanes. homes.push(getSystemCodexHomePath()) } - // Why: flag ON routes each managed host account to its own self-contained - // home, so its rollouts live there rather than in the shared mirror. Scan - // every such home — plus any that retained rollouts across an opt-out — so - // account-scoped sessions still surface in the AI Vault. + // Why: each managed host account runs in its own self-contained home, so + // its rollouts live there rather than in the shared mirror. Scan every such + // home so account-scoped sessions still surface in the AI Vault. for (const perAccountHome of this.getManagedHostAccountHomesForSessionDiscovery()) { homes.push(perAccountHome) } @@ -475,9 +464,8 @@ export class CodexRuntimeHomeService { /** * The account-owned CODEX_HOME the current HOST selection runs against, or - * null when the selection is not routed to one (system default, or the - * flag-OFF shared mirror, which every account hot-swaps and so names no - * account). + * null when the selection is not routed to one (system default, or a WSL + * account, whose home lives inside the distro). * * Read-only on purpose: session discovery ranks homes with this before any * launch prep, so it must create no directories and sync no auth. @@ -505,13 +493,13 @@ export class CodexRuntimeHomeService { this.realHomeLaneGate = gate } - // Why: real-home routing applies only to the host system-default selection - // with the staged flag ON. Managed accounts keep hot-swap isolation; custom - // CODEX_HOMEs stay managed until phase 1 can track cleanup across old homes. + // Why: real-home routing applies only to the host system-default selection. + // Managed accounts run in their own homes; Windows (no shell-startup probe) + // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked + // across old homes. isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { const settings = this.store.getSettings() if ( - !isCodexSystemDefaultRealHomeEnabled() || normalizeCodexRuntimeSelection(settings).host !== null || !isShellStartupEnvProbeSupported() ) { @@ -640,9 +628,9 @@ export class CodexRuntimeHomeService { } const settings = this.store.getSettings() if (this.lastHostAccountUsedSelfContainedHome) { - // Why: E auth is already canonical in the per-account home. Reset the - // legacy mirror baseline without reading it; flag-OFF can then seed the - // mirror from canonical storage, while real-home deselect needs no sync. + // Why: the account's auth is already canonical in its own home. Reset the + // legacy mirror baseline without reading it; a real-home deselect needs no + // further sync, and the mirror lane below re-seeds from canonical storage. this.lastHostAccountUsedSelfContainedHome = false this.lastSyncedAccountId = null this.lastWrittenAuthJson = null @@ -668,105 +656,16 @@ export class CodexRuntimeHomeService { settings.codexManagedAccounts, normalizeCodexRuntimeSelection(settings).host ) - const previousAccount = this.getActiveAccount( - settings.codexManagedAccounts, - this.lastSyncedAccountId - ) - if (this.getWslManagedHomePath(activeAccount)) { - const previousWasHostManaged = previousAccount && !this.getWslManagedHomePath(previousAccount) - const outgoingReadBackResult = previousWasHostManaged - ? this.readBackRefreshedTokensForAccount(previousAccount, { - updateLastWrittenAuthJson: false - }) - : 'unchanged' - if (previousWasHostManaged) { - this.restoreSystemDefaultSnapshot({ - detectExternalLogin: outgoingReadBackResult !== 'rejected' - }) - } + if (activeAccount) { + // Why: only a WSL-managed account can reach here — every host account was + // routed to its own self-contained home above. Its auth lives in the + // distro-local runtime home, so the host mirror only drops its baseline. this.lastSyncedAccountId = null this.lastWrittenAuthJson = null this.skipNextReadBackForAccountId = null return } - let outgoingReadBackResult: CodexReadBackResult = 'unchanged' - if (previousAccount && previousAccount.id !== activeAccount?.id) { - outgoingReadBackResult = this.readBackRefreshedTokensForAccount(previousAccount, { - updateLastWrittenAuthJson: true - }) - } - if (!activeAccount) { - if (normalizeCodexRuntimeSelection(settings).host) { - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - } - // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. - if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ - detectExternalLogin: outgoingReadBackResult !== 'rejected' - }) - this.lastSyncedAccountId = null - } else if (!runtimeAuthExistedBeforeSync) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - if (logoutMarkerStatus.kind === 'applies') { - this.lastWrittenAuthJson = null - } else if ( - logoutMarkerStatus.kind === 'system-default-changed' && - logoutMarkerStatus.systemDefaultAuthJson !== null - ) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else if (logoutMarkerStatus.kind === 'system-default-changed') { - // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - } else if (this.lastWrittenAuthJson === null) { - // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else { - this.persistRuntimeLogoutMarker() - } - } else { - this.clearRuntimeLogoutMarker() - this.syncRuntimeAuthWithSystemDefault() - } - return - } - - const activeAuthPath = join(activeAccount.managedHomePath, 'auth.json') - const authAbsence = this.credentialAbsenceGrace.assess(activeAuthPath) - if (authAbsence.state !== 'present' && authAbsence.state !== 'incomplete') { - if (!authAbsence.durable) { - if (this.sharedRuntimeAuthBelongsToAccount(activeAccount)) { - // Why: mid-rotation reads look missing/unreadable for a moment; skip - // this sync without deselecting and let a settled read decide later. - console.warn( - '[codex-runtime-home] Active managed account auth.json unavailable, keeping selection through grace window' - ) - return - } - // Why: the runtime home still holds another account, so riding out the - // grace would launch that account under this selection. Not being able - // to read the selected account is no license to run a different one. - console.warn( - '[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth' - ) - this.clearRuntimeAuthForUnprovenSelection() - return - } - console.warn( - '[codex-runtime-home] Active managed account credential is unavailable, restoring system default' - ) - // Why: valid credential-free JSON is an explicit logout; never revive it - // from stale shared-home bytes while clearing the selection. - if (authAbsence.state !== 'no-credential' && this.lastSyncedAccountId === activeAccount.id) { - outgoingReadBackResult = this.recoverRefreshForMissingActiveAccount(activeAccount) - } + if (normalizeCodexRuntimeSelection(settings).host) { this.store.updateSettings({ activeCodexManagedAccountId: null, activeCodexManagedAccountIdsByRuntime: { @@ -774,38 +673,35 @@ export class CodexRuntimeHomeService { host: null } }) - if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ - detectExternalLogin: outgoingReadBackResult !== 'rejected' - }) - this.lastSyncedAccountId = null - } - return } - - if (this.lastSyncedAccountId === null) { - this.captureSystemDefaultSnapshot({ force: true }) - } - - // Why: Codex refreshes OAuth tokens in the runtime auth.json; if it differs from Orca's last write, read those back to managed storage before overwriting. - if (this.lastSyncedAccountId === activeAccount.id) { - if (this.skipNextReadBackForAccountId === activeAccount.id) { - this.skipNextReadBackForAccountId = null + // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. + if (this.lastSyncedAccountId !== null) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) + this.lastSyncedAccountId = null + } else if (!runtimeAuthExistedBeforeSync) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + if (logoutMarkerStatus.kind === 'applies') { + this.lastWrittenAuthJson = null + } else if ( + logoutMarkerStatus.kind === 'system-default-changed' && + logoutMarkerStatus.systemDefaultAuthJson !== null + ) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else if (logoutMarkerStatus.kind === 'system-default-changed') { + // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + } else if (this.lastWrittenAuthJson === null) { + // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) } else { - this.readBackRefreshedTokens({ - updateLastWrittenAuthJson: true - }) + this.persistRuntimeLogoutMarker() } + } else { + this.clearRuntimeLogoutMarker() + this.syncRuntimeAuthWithSystemDefault() } - - if (this.lastSyncedAccountId !== activeAccount.id) { - this.skipNextReadBackForAccountId = null - } - this.lastSyncedAccountId = activeAccount.id - this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'), { - owner: 'managed', - accountId: activeAccount.id - }) } // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. @@ -818,26 +714,6 @@ export class CodexRuntimeHomeService { this.skipNextReadBackForAccountId = accountId } - private readBackRefreshedTokens(options: { - updateLastWrittenAuthJson: boolean - }): CodexReadBackResult { - const selectedAccountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host - if (selectedAccountId) { - const selectedAccountResult = this.readBackRefreshedTokensFromPath( - this.getRuntimeAuthPath(), - { - ...options, - expectedAccountId: selectedAccountId - } - ) - if (selectedAccountResult !== 'rejected') { - return selectedAccountResult - } - } - - return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), options) - } - private readBackRefreshedTokensFromPath( runtimeAuthPath: string, options: { @@ -895,119 +771,6 @@ export class CodexRuntimeHomeService { } } - private readBackRefreshedTokensForAccount( - account: CodexManagedAccount, - options: { updateLastWrittenAuthJson: boolean } - ): CodexReadBackResult { - return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), { - ...options, - expectedAccountId: account.id - }) - } - - private recoverRefreshForMissingActiveAccount(account: CodexManagedAccount): CodexReadBackResult { - try { - const runtimeAuthPath = this.getRuntimeAuthPath() - if (!existsSync(runtimeAuthPath) || this.lastWrittenAuthJson === null) { - return 'rejected' - } - const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (runtimeContents === this.lastWrittenAuthJson) { - return 'unchanged' - } - // Why: the canonical file is gone, so the exact in-memory bytes Orca - // previously mirrored are the only safe identity baseline for recovery. - if (!codexAuthMatchesManagedAccount(runtimeContents, account, this.lastWrittenAuthJson)) { - return 'rejected' - } - writeFileAtomically(join(account.managedHomePath, 'auth.json'), runtimeContents, { - mode: 0o600 - }) - this.lastWrittenAuthJson = runtimeContents - return 'persisted' - } catch (error) { - console.warn('[codex-runtime-home] Failed to recover missing managed auth:', error) - return 'rejected' - } - } - - // Why: stale panes can overwrite the mirror after provenance is committed, so - // launch ownership needs current-byte identity or Orca's exact same-run write. - private sharedRuntimeAuthBelongsToAccount(account: CodexManagedAccount): boolean { - if (!existsSync(this.getRuntimeAuthPath())) { - return true - } - const runtimeAuth = this.readRuntimeAuthForProvenance() - if (runtimeAuth !== null) { - if (codexAuthMatchesManagedAccount(runtimeAuth, account, null)) { - return true - } - // Why: Orca itself mirrored these exact bytes for this account this run. - if (this.lastSyncedAccountId === account.id && this.lastWrittenAuthJson === runtimeAuth) { - return true - } - } - return false - } - - // Why: the absence may still heal, so keep the selection — but leave no other - // identity's credentials behind for the launch. Logged out beats logged in as - // someone else, and fencing stops later syncs adopting the removed bytes. - private clearRuntimeAuthForUnprovenSelection(): void { - const runtimeAuthPath = this.getRuntimeAuthPath() - try { - // Why: a refresh Codex wrote into the mirror belongs to whoever owns - // those bytes; persist it to that owner's home — managed or ~/.codex — - // first, then fence so later syncs cannot adopt the removed bytes. - const readBackResult = this.readBackRefreshedTokensFromPath(runtimeAuthPath, { - updateLastWrittenAuthJson: false - }) - if (readBackResult === 'rejected') { - this.readBackRefreshedSystemDefaultAuth() - } - this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) - } catch (error) { - // Why: rescue and metadata are best-effort; neither may leave another - // identity's credentials in the home returned to the launch. - console.warn('[codex-runtime-home] Failed to rescue or fence unproven runtime auth:', error) - } - this.lastWrittenAuthJson = null - try { - rmSync(runtimeAuthPath, { force: true }) - } catch (error) { - // Why: a fence is Orca metadata that Codex does not read. If Windows or - // another host keeps auth.json locked, failing launch is the safe result. - throw new Error('Cannot safely launch Codex while stale runtime auth remains.', { - cause: error - }) - } - } - - // Why: a token Codex refreshed inside the mirror has no managed home to fall - // back to when the mirror holds the user's own ~/.codex credential, so persist - // it there before the unproven mirror is dropped. - private readBackRefreshedSystemDefaultAuth(): void { - const runtimeAuth = this.readRuntimeAuthForProvenance() - const systemDefaultAuth = this.readSystemDefaultAuth() - if (runtimeAuth === null || systemDefaultAuth === null || runtimeAuth === systemDefaultAuth) { - return - } - const claim = this.resolveSystemDefaultMirrorClaim( - runtimeAuth, - this.resolveSharedRuntimeAuthProvenanceStatus() - ) - if ( - !claim.ownershipProven || - claim.mirroredAuthJson === null || - systemDefaultAuth !== claim.mirroredAuthJson || - !this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemDefaultAuth) - ) { - return - } - this.writeSystemDefaultAuth(runtimeAuth) - this.captureSystemDefaultSnapshot({ force: true }) - } - // Why: which ~/.codex bytes the mirror was seeded from, and whether the system // default can be proven to own the mirror at all. private resolveSystemDefaultMirrorClaim( @@ -1399,9 +1162,6 @@ export class CodexRuntimeHomeService { private safeMigrateLegacySharedAuth(): void { const settings = this.store.getSettings() - if (!isCodexSystemDefaultRealHomeEnabled()) { - return - } try { migrateLegacySharedAuthToPerAccountHome({ activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, diff --git a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts index a1a66a847d8..54ab5d5f095 100644 --- a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts +++ b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts @@ -3,7 +3,6 @@ import type { GlobalSettings } from '../../shared/types' import { CodexRuntimeHomeService } from './runtime-home-service' const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') -const originalRealHomeOverride = process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME const originalCodexHome = process.env.CODEX_HOME const originalOrcaCodexHome = process.env.ORCA_CODEX_HOME @@ -11,7 +10,6 @@ afterEach(() => { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } - restoreEnv('ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', originalRealHomeOverride) restoreEnv('CODEX_HOME', originalCodexHome) restoreEnv('ORCA_CODEX_HOME', originalOrcaCodexHome) }) @@ -19,7 +17,6 @@ afterEach(() => { describe('Windows System Default Codex home ownership', () => { it('stays managed when PowerShell profile state cannot be inspected', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = '1' delete process.env.CODEX_HOME delete process.env.ORCA_CODEX_HOME diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 175a42e6dbd..47fd0c67850 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -46,33 +46,10 @@ function decodeEncodedWslBashCommand(command: string): string { return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command } -// Why: the shipped code no longer reads a settings flag — the legacy mirror -// lane is reachable only through the test-rig env override. Route the old -// per-test override key to that env var so lane coverage keeps working. -type TestSettingsOverrides = Partial & { - codexSystemDefaultRealHomeEnabled?: boolean -} - -function setRealHomeLaneForTest(enabled: boolean): void { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = enabled ? '1' : '0' -} - -const initialRealHomeLaneEnv = process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME -afterEach(() => { - if (initialRealHomeLaneEnv === undefined) { - delete process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME - } else { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = initialRealHomeLaneEnv - } -}) - -function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { +function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false - // Config-sync/hot-swap tests assert the shared-mirror path; production is - // real-home always, so opt these managed cases out unless a test overrides it. - setRealHomeLaneForTest(overrides.codexSystemDefaultRealHomeEnabled ?? false) return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -430,7 +407,6 @@ describe('CodexAccountService config sync', () => { 'approval_policy = "on-request"\n' ) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, codexManagedAccounts: [ { id: 'account-1', @@ -476,43 +452,6 @@ describe('CodexAccountService config sync', () => { expectSanitizedManagedConfig() }) - it('keeps flag-off config mirroring byte-identical', async () => { - const fixture = await createCanonicalHookTrustFixture() - const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') - writeFileSync(canonicalConfigPath, fixture.config, 'utf-8') - const managedHomePath = createManagedHome( - testState.userDataDir, - 'account-1', - 'approval_policy = "on-request"\n' - ) - const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ] - }) - - const { CodexAccountService } = await import('./service') - new CodexAccountService( - createStore(settings) as never, - createRateLimits() as never, - createRuntimeHome() as never - ) - - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(fixture.config) - expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(fixture.config) - }) - it('rewrites relative path config values when syncing into managed homes', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') writeFileSync( @@ -556,7 +495,7 @@ describe('CodexAccountService config sync', () => { expect(managedConfig).toContain('sandbox_mode = "danger-full-access"') }) - it('does not rewrite managed configs that already match canonical config', async () => { + it('does not rewrite a managed config the previous mirror pass already settled', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') const { escapeTomlString } = await import('../codex/config-toml-trust') const userHookKey = `${join(testState.fakeHomeDir, '.codex', 'user-hooks.json')}:stop:0:0` @@ -575,8 +514,6 @@ describe('CodexAccountService config sync', () => { '{"account":"managed"}\n' ) const managedConfigPath = join(managedHomePath, 'config.toml') - const oldDate = new Date('2024-01-01T00:00:00.000Z') - utimesSync(managedConfigPath, oldDate, oldDate) const settings = createSettings({ codexManagedAccounts: [ { @@ -600,6 +537,15 @@ describe('CodexAccountService config sync', () => { const { CodexAccountService } = await import('./service') new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) + // The first pass remaps the user hook-trust entry into this home; once that + // has settled, a later pass must leave the file completely untouched. + const settledConfig = readFileSync(managedConfigPath, 'utf-8') + const oldDate = new Date('2024-01-01T00:00:00.000Z') + utimesSync(managedConfigPath, oldDate, oldDate) + + new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) + + expect(readFileSync(managedConfigPath, 'utf-8')).toBe(settledConfig) expect(statSync(managedConfigPath).mtimeMs).toBeLessThan(Date.now() - 60_000) }) @@ -659,8 +605,7 @@ describe('CodexAccountService config sync', () => { it('re-syncs config when selecting an account', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') - const canonicalConfig = 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n' - writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') + writeFileSync(canonicalConfigPath, 'sandbox_mode = "danger-full-access"\n', 'utf-8') const managedHomePath = createManagedHome( testState.userDataDir, 'account-1', @@ -696,7 +641,11 @@ describe('CodexAccountService config sync', () => { await service.selectAccount('account-1') - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + // Selecting merges canonical settings into the account's own home rather + // than overwriting it, so its local approval_policy survives the re-sync. + expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe( + 'sandbox_mode = "danger-full-access"\napproval_policy = "untrusted"\n' + ) expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(1) expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) @@ -855,7 +804,7 @@ describe('CodexAccountService config sync', () => { readHookTrustEntries = (await import('../codex/config-toml-trust')).readHookTrustEntries writeFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), fixture.config, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings()) const rateLimits = createRateLimits() const runtimeHome = createRuntimeHome() const { CodexAccountService } = await import('./service') @@ -1842,7 +1791,7 @@ describe('CodexAccountService config sync', () => { expect(onHostSystemDefaultSelected).toHaveBeenCalledOnce() }) - it('selectAccount immediately rewrites the shared runtime auth for existing terminals', async () => { + it('selectAccount switches managed accounts without routing auth through the shared mirror', async () => { const firstAuth = createCodexAuthJson('one@example.com', 'acct-one', 'one') const secondAuth = createCodexAuthJson('two@example.com', 'acct-two', 'two') const firstManagedHomePath = createManagedHome( @@ -1890,7 +1839,7 @@ describe('CodexAccountService config sync', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const runtimeHome = new CodexRuntimeHomeService(store as never) const runtimeAuthPath = join(testState.userDataDir, 'codex-runtime-home', 'home', 'auth.json') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(firstAuth) + expect(existsSync(runtimeAuthPath)).toBe(false) const { CodexAccountService } = await import('./service') const service = new CodexAccountService( @@ -1901,7 +1850,11 @@ describe('CodexAccountService config sync', () => { await service.selectAccount('account-2') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(secondAuth) + // Each managed host account launches against its own home, so a switch must + // leave both credential files alone and never copy either into the mirror. + expect(existsSync(runtimeAuthPath)).toBe(false) + expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) expect(existsSync(join(testState.userDataDir, 'codex-runtime-home', 'launch'))).toBe(false) expect(existsSync(join(testState.userDataDir, 'codex-runtime-home', 'active'))).toBe(false) }) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 3618f138144..b55c9b50315 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -30,7 +30,6 @@ import type { CodexRuntimeHomeService } from './runtime-home-service' import { writeFileAtomically } from './fs-utils' import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite' import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation' -import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { getCodexManagedHookInstallMaterial } from '../codex/hook-service' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { getSystemCodexHomePath } from '../codex/codex-home-paths' @@ -1238,9 +1237,9 @@ export class CodexAccountService { } private isSelfContainedHostManagedHome(managedHomePath: string): boolean { - // Why: flag ON makes each host account home its own launch CODEX_HOME. WSL - // homes keep their distro-local seed lane; the flag-OFF opt-out is unchanged. - return isCodexSystemDefaultRealHomeEnabled() && !parseWslUncPath(managedHomePath) + // Why: each host account home is its own launch CODEX_HOME. WSL homes keep + // their distro-local seed lane. + return !parseWslUncPath(managedHomePath) } private syncCanonicalConfigIntoManagedHome( @@ -1270,18 +1269,15 @@ export class CodexAccountService { // account while preserving consistent Codex behavior. Managed homes are // real CODEX_HOMEs for `codex login`, so relative path-valued settings // must keep resolving against the home the config was read from. - let sanitizedConfig = canonicalConfig.contents - if (isCodexSystemDefaultRealHomeEnabled()) { - const material = getCodexManagedHookInstallMaterial() - // Why: source-home Orca trust is foreign to each managed home's hooks.json. - sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, { - runtimeHomePath: canonicalConfig.sourceHomePath, - sourcePath: canonicalConfig.sourceHooksPath, - command: material.command, - managedEventLabels: new Set(Object.values(material.eventLabel)), - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - }) - } + const material = getCodexManagedHookInstallMaterial() + // Why: source-home Orca trust is foreign to each managed home's hooks.json. + const sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, { + runtimeHomePath: canonicalConfig.sourceHomePath, + sourcePath: canonicalConfig.sourceHooksPath, + command: material.command, + managedEventLabels: new Set(Object.values(material.eventLabel)), + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) this.writeManagedConfig( trustedManagedHomePath, rewriteRelativePathConfigValues(sanitizedConfig, canonicalConfig.sourceHomePath) diff --git a/src/main/codex/codex-real-home-flag.test.ts b/src/main/codex/codex-real-home-flag.test.ts deleted file mode 100644 index 63b6889a757..00000000000 --- a/src/main/codex/codex-real-home-flag.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { isCodexSystemDefaultRealHomeEnabled } from './codex-real-home-flag' - -const ENV_FLAG = 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME' -let previousEnvFlag: string | undefined - -beforeEach(() => { - previousEnvFlag = process.env[ENV_FLAG] - delete process.env[ENV_FLAG] -}) - -afterEach(() => { - if (previousEnvFlag === undefined) { - delete process.env[ENV_FLAG] - } else { - process.env[ENV_FLAG] = previousEnvFlag - } -}) - -describe('isCodexSystemDefaultRealHomeEnabled', () => { - it('is unconditionally ON in production (no settings consulted)', () => { - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(true) - }) - - it('lets the test-rig env override force ON explicitly', () => { - for (const raw of ['1', 'true', 'on', 'TRUE', ' On ']) { - process.env[ENV_FLAG] = raw - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(true) - } - }) - - it('lets the test-rig env override pin the legacy managed lane OFF', () => { - for (const raw of ['0', 'false', 'off']) { - process.env[ENV_FLAG] = raw - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(false) - } - }) - - it('ignores an unrecognized env value and stays ON', () => { - process.env[ENV_FLAG] = 'maybe' - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(true) - }) -}) diff --git a/src/main/codex/codex-real-home-flag.ts b/src/main/codex/codex-real-home-flag.ts deleted file mode 100644 index 541dd98efa8..00000000000 --- a/src/main/codex/codex-real-home-flag.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Routing truth for the SYSTEM-DEFAULT Codex account: it always runs against - * the user's real ~/.codex; managed (multi-account) selections always get - * their own self-contained homes. There is no user-facing setting — the - * feature ships unconditionally. - * - * The env override exists only for test rigs (containment harness, e2e home - * isolation, CDP verification) that must pin the legacy managed-home lane or - * force the real-home lane inside a disposable HOME. It never appears in any - * UI and no production path sets it. - */ -const CODEX_REAL_HOME_ENV_FLAG = 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME' - -export function isCodexSystemDefaultRealHomeEnabled(): boolean { - const envOverride = readCodexRealHomeEnvOverride() - if (envOverride !== null) { - return envOverride - } - return true -} - -function readCodexRealHomeEnvOverride(): boolean | null { - const raw = process.env[CODEX_REAL_HOME_ENV_FLAG] - if (raw === undefined) { - return null - } - const normalized = raw.trim().toLowerCase() - if (normalized === '1' || normalized === 'true' || normalized === 'on') { - return true - } - if (normalized === '0' || normalized === 'false' || normalized === 'off') { - return false - } - return null -} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 62448ffd517..e4f50a62501 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -198,7 +198,6 @@ import { } from '../codex/codex-pane-account-registry' import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { environmentCodexHomeOverrideContextsEqual, getCustomCodexHomeOverrideForLaunch, @@ -1117,10 +1116,7 @@ function shouldStripInheritedOrcaCodexHome(args: { settings: GlobalSettings | undefined }): boolean { return ( - args.target.runtime === 'host' && - args.selectedCodexHomePath === null && - !args.skipCodexHomeEnv && - isCodexSystemDefaultRealHomeEnabled() + args.target.runtime === 'host' && args.selectedCodexHomePath === null && !args.skipCodexHomeEnv ) } diff --git a/tests/e2e/electron-home-isolation.spec.ts b/tests/e2e/electron-home-isolation.spec.ts index 79af5114661..65aa1b7dc10 100644 --- a/tests/e2e/electron-home-isolation.spec.ts +++ b/tests/e2e/electron-home-isolation.spec.ts @@ -14,12 +14,13 @@ async function readElectronHomeState(electronApp: ElectronApplication) { home: process.env.HOME, userProfile: process.env.USERPROFILE, codexHome: process.env.CODEX_HOME, - orcaCodexHome: process.env.ORCA_CODEX_HOME, - realHomeFlag: process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME + orcaCodexHome: process.env.ORCA_CODEX_HOME } }) } +// Codex always routes to the real home now, so this single case covers both the +// HOME boundary and that real-home routing lands inside the disposable profile. test('isolates Electron and Codex from the developer home by default', async ({ electronApp }) => { const state = await readElectronHomeState(electronApp) const expectedHome = path.join(state.userDataDir!, 'home') @@ -30,17 +31,4 @@ test('isolates Electron and Codex from the developer home by default', async ({ expect(state.userProfile).toBe(expectedHome) expect(state.codexHome).toBeUndefined() expect(state.orcaCodexHome).toBeUndefined() - expect(state.realHomeFlag).toBe('0') -}) - -test.describe('sandboxed real-home routing', () => { - test.use({ codexRealHomeEnabled: true }) - - test('keeps flag-ON routing inside the disposable home', async ({ electronApp }) => { - const state = await readElectronHomeState(electronApp) - - expect(state.appHome).toBe(path.join(state.userDataDir!, 'home')) - expect(state.nodeHome).toBe(path.join(state.userDataDir!, 'home')) - expect(state.realHomeFlag).toBe('1') - }) }) diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts index e19e0dabf9f..1d4b4a0f82c 100644 --- a/tests/e2e/headless-serve-desktop-activation.spec.ts +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -53,8 +53,7 @@ function createHeadlessLaunchIsolation(userDataDir: string): ElectronHomeIsolati ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' }, extraEnv: {}, - userDataDir, - codexRealHomeEnabled: false + userDataDir }) } diff --git a/tests/e2e/helpers/computer-cli-driver.ts b/tests/e2e/helpers/computer-cli-driver.ts index 7e9d8b1a6ec..a96b55d5f2a 100644 --- a/tests/e2e/helpers/computer-cli-driver.ts +++ b/tests/e2e/helpers/computer-cli-driver.ts @@ -174,8 +174,7 @@ async function createComputerE2ERuntimeEnv(): Promise { inheritedEnv, launchEnv: {}, extraEnv: {}, - userDataDir, - codexRealHomeEnabled: false + userDataDir }) return { ...isolation.env, diff --git a/tests/e2e/helpers/electron-home-isolation.ts b/tests/e2e/helpers/electron-home-isolation.ts index fd526a36efc..e81cbae1907 100644 --- a/tests/e2e/helpers/electron-home-isolation.ts +++ b/tests/e2e/helpers/electron-home-isolation.ts @@ -9,7 +9,6 @@ const RESTRICTED_ENV_KEYS = new Set([ 'HOMEPATH', 'CODEX_HOME', 'ORCA_CODEX_HOME', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_E2E_USER_DATA_DIR', 'ORCA_E2E_HOME_DIR', 'ZDOTDIR', @@ -23,7 +22,6 @@ type ElectronHomeIsolationOptions = { launchEnv: NodeJS.ProcessEnv extraEnv: Record userDataDir: string - codexRealHomeEnabled: boolean realHome?: string } @@ -50,9 +48,7 @@ function assertOverlayDoesNotReplaceIsolation( RESTRICTED_ENV_KEYS.has(key.toUpperCase()) ) if (restrictedKey) { - throw new Error( - `${overlayName}.${restrictedKey} cannot override the E2E home boundary; use codexRealHomeEnabled for sandboxed real-home coverage` - ) + throw new Error(`${overlayName}.${restrictedKey} cannot override the E2E home boundary`) } } @@ -67,7 +63,6 @@ export function createElectronHomeIsolation({ launchEnv, extraEnv, userDataDir, - codexRealHomeEnabled, realHome = os.homedir() }: ElectronHomeIsolationOptions): ElectronHomeIsolation { assertOverlayDoesNotReplaceIsolation(launchEnv, 'launchEnv') @@ -95,8 +90,7 @@ export function createElectronHomeIsolation({ HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_USER_DATA_DIR: userDataDir, - ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: codexRealHomeEnabled ? '1' : '0' + ORCA_E2E_HOME_DIR: isolatedHome } } } diff --git a/tests/e2e/helpers/electron-home-isolation.unit.test.ts b/tests/e2e/helpers/electron-home-isolation.unit.test.ts index ab1ed9901fd..b84770bcdeb 100644 --- a/tests/e2e/helpers/electron-home-isolation.unit.test.ts +++ b/tests/e2e/helpers/electron-home-isolation.unit.test.ts @@ -37,7 +37,6 @@ describe('createElectronHomeIsolation', () => { launchEnv: { TEST_TOKEN: 'safe' }, extraEnv: { EXTRA_TEST_FLAG: '1' }, userDataDir, - codexRealHomeEnabled: false, realHome: '/real/home' }) @@ -51,12 +50,16 @@ describe('createElectronHomeIsolation', () => { EXTRA_TEST_FLAG: '1', HOME: canonicalHome, USERPROFILE: canonicalHome, - ORCA_E2E_USER_DATA_DIR: userDataDir, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0' + ORCA_E2E_USER_DATA_DIR: userDataDir }) expect(isolation.env.CODEX_HOME).toBeUndefined() expect(isolation.env.ORCA_CODEX_HOME).toBeUndefined() expect(isolation.env.ZDOTDIR).toBeUndefined() + // Codex always routes to the resolved home, so the post-launch guard must + // accept the boundary this env produces. + expect(() => + assertElectronResolvedIsolatedHome(isolation.isolatedHome, isolation) + ).not.toThrow() }) it('rejects generic fixture overlays that could escape the boundary', () => { @@ -66,7 +69,6 @@ describe('createElectronHomeIsolation', () => { launchEnv: { CODEX_HOME: '/unsafe' }, extraEnv: {}, userDataDir: createUserDataDir(), - codexRealHomeEnabled: false, realHome: '/real/home' }) ).toThrow(/launchEnv\.CODEX_HOME/) @@ -77,28 +79,11 @@ describe('createElectronHomeIsolation', () => { launchEnv: {}, extraEnv: { ORCA_E2E_USER_DATA_DIR: '/unsafe' }, userDataDir: createUserDataDir(), - codexRealHomeEnabled: false, realHome: '/real/home' }) ).toThrow(/orcaAppExtraEnv\.ORCA_E2E_USER_DATA_DIR/) }) - it('keeps real-home routing inside the disposable home when explicitly enabled', () => { - const isolation = createElectronHomeIsolation({ - inheritedEnv: {}, - launchEnv: {}, - extraEnv: {}, - userDataDir: createUserDataDir(), - codexRealHomeEnabled: true, - realHome: '/real/home' - }) - - expect(isolation.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1') - expect(() => - assertElectronResolvedIsolatedHome(isolation.isolatedHome, isolation) - ).not.toThrow() - }) - it('compares Windows home paths case-insensitively', () => { expect(areSameHomePath('C:\\Users\\Alice', 'c:\\users\\alice', 'win32')).toBe(true) }) diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index 9159154b25d..dec59682938 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -201,8 +201,7 @@ export async function launchHeadlessPairedRuntimeHost(): Promise({ launchEnv, orcaAppExtraEnv, orcaAppExtraArgs, - codexRealHomeEnabled, registerPostElectronShutdownCleanup }, provideFixture, @@ -212,8 +208,7 @@ export const test = base.extend({ inheritedEnv: cleanEnv, launchEnv, extraEnv: orcaAppExtraEnv, - userDataDir, - codexRealHomeEnabled + userDataDir }) // Why: ORCA_E2E_SLOWMO_MS adds a pause between every Playwright action so a // developer running with ORCA_E2E_FORCE_HEADFUL=1 can actually watch what @@ -281,7 +276,6 @@ export const test = base.extend({ launchEnv: [{}, { option: true }], orcaAppExtraEnv: [{}, { option: true }], orcaAppExtraArgs: [[], { option: true }], - codexRealHomeEnabled: [false, { option: true }], // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index 609ff1797bc..c7a93f81450 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -120,8 +120,7 @@ function createRestartLaunchIsolation( ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) }, extraEnv: {}, - userDataDir, - codexRealHomeEnabled: false + userDataDir }) } diff --git a/tests/e2e/helpers/paired-electron-client.ts b/tests/e2e/helpers/paired-electron-client.ts index c33764a243a..07bb467780f 100644 --- a/tests/e2e/helpers/paired-electron-client.ts +++ b/tests/e2e/helpers/paired-electron-client.ts @@ -155,8 +155,7 @@ export async function launchPairedElectronClient( inheritedEnv: cleanEnv, launchEnv: {}, extraEnv: {}, - userDataDir, - codexRealHomeEnabled: false + userDataDir }) const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const app = await electron.launch({ diff --git a/tests/tools/benchmarks/daemon-coldstart-bench.mjs b/tests/tools/benchmarks/daemon-coldstart-bench.mjs index eebc02a3dba..5e234d8b6a2 100644 --- a/tests/tools/benchmarks/daemon-coldstart-bench.mjs +++ b/tests/tools/benchmarks/daemon-coldstart-bench.mjs @@ -177,7 +177,6 @@ function runIteration({ exe, fixtureDir, timeoutMs, lingerMs }) { HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ORCA_E2E_HEADLESS: '1' } delete env.CODEX_HOME diff --git a/tests/tools/benchmarks/main-thread-jank-bench.mjs b/tests/tools/benchmarks/main-thread-jank-bench.mjs index 914c72a67f8..a64eecaf985 100644 --- a/tests/tools/benchmarks/main-thread-jank-bench.mjs +++ b/tests/tools/benchmarks/main-thread-jank-bench.mjs @@ -289,8 +289,7 @@ async function main() { ORCA_E2E_USER_DATA_DIR: fixtureDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0' + ORCA_E2E_HOME_DIR: isolatedHome } delete env.CODEX_HOME delete env.ORCA_CODEX_HOME diff --git a/tests/tools/benchmarks/startup-time-bench.mjs b/tests/tools/benchmarks/startup-time-bench.mjs index 3e64ee4a8a1..29363432c7a 100644 --- a/tests/tools/benchmarks/startup-time-bench.mjs +++ b/tests/tools/benchmarks/startup-time-bench.mjs @@ -364,7 +364,6 @@ function buildLaunchEnvironment({ fixtureDir, githubRepos, ghShimDir }) { HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ORCA_E2E_HEADLESS: '1' } delete env.CODEX_HOME diff --git a/tests/tools/win-update-e2e/app-driver.mjs b/tests/tools/win-update-e2e/app-driver.mjs index 72603fba921..d95e7f2c342 100644 --- a/tests/tools/win-update-e2e/app-driver.mjs +++ b/tests/tools/win-update-e2e/app-driver.mjs @@ -35,7 +35,6 @@ const RESTRICTED_E2E_ENV_KEYS = new Set([ 'USERPROFILE', 'CODEX_HOME', 'ORCA_CODEX_HOME', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_E2E_HOME_DIR', 'ORCA_E2E_USER_DATA_DIR' ]) @@ -92,8 +91,7 @@ export async function launchInstalledApp({ ORCA_E2E_USER_DATA_DIR: userDataDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0' + ORCA_E2E_HOME_DIR: isolatedHome } }) // If firstWindow times out (the launched main never shows a window), the From 23238aee0b5980af9611c7b5006ef96c9daae0de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:13:05 -0700 Subject: [PATCH 02/46] fix(mobile): stop native-chat send button flicker (#12764) * fix(mobile): stop native-chat send button flicker * fix(mobile): keep composer lock rendering pure --- .../src/session/MobileNativeChatView.test.ts | 49 ++++++++++++++++++- mobile/src/session/MobileNativeChatView.tsx | 16 +++--- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 05763d59707..21a5a7c4aa1 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -50,8 +50,13 @@ vi.mock('./MobileAgentWorkingIndicator', () => ({ vi.mock('./MobileNativeChatComposer', async () => { const React = await import('react') return { - MobileNativeChatComposer: (props: { onSend: (text: string) => Promise }) => + MobileNativeChatComposer: (props: { + onSend: (text: string) => Promise + disabled?: boolean + placeholder?: string + }) => React.createElement('Composer', { + ...props, accessibilityLabel: 'Send message', onPress: () => props.onSend('hi') }) @@ -136,6 +141,10 @@ describe('MobileNativeChatView', () => { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } + function composer(): ReactTestInstance { + return renderer!.root.find((node) => node.type === 'Composer') + } + function bannerText(): string { const [alert, ...rest] = banners() expect(rest).toHaveLength(0) @@ -197,4 +206,42 @@ describe('MobileNativeChatView', () => { expect(listIds()).toEqual(['a1', 'streaming']) }) + + it('keeps a visible lock through a subscribed-end lease blip', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + expect(composer().props.disabled).toBe(true) + + await update({ inputLockReason: null }) + expect(composer().props.disabled).toBe(true) + await act(async () => vi.advanceTimersByTime(300)) + await update({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + + expect(composer().props.disabled).toBe(true) + expect(composer().props.placeholder).toBe('Waiting for terminal…') + } finally { + vi.useRealTimers() + } + }) + + it('unlocks after the lease stays ready', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + await update({ inputLockReason: null }) + await act(async () => vi.advanceTimersByTime(599)) + expect(composer().props.disabled).toBe(true) + + await act(async () => vi.advanceTimersByTime(1)) + + expect(composer().props.disabled).toBe(false) + expect(composer().props.placeholder).toBe('Message, @files, /commands') + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index a35f3caed3c..48a1fcd31d3 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -33,6 +33,8 @@ import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' +const INPUT_LOCK_SETTLE_MS = 600 + /** Why the composer input is locked: the transport is disconnected, or the * terminal subscription has not acknowledged its input lease yet. */ export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' @@ -250,20 +252,18 @@ export function MobileNativeChatView({ const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 - // Composer-lock flicker guard: on a remote link, brief connState blips or lease - // hand-offs would otherwise toggle the lock placeholder on and off. Only surface - // a lock once it has held ~600ms; drop it instantly so unlocking stays snappy. + // A dead PTY emits subscribed→end; settle both edges so its false lease cannot flash the composer enabled. const rawLockReason = inputLockReason ?? null + const rawLockHeld = rawLockReason !== null const [lockHeld, setLockHeld] = useState(false) useEffect(() => { - if (rawLockReason === null) { - setLockHeld(false) + if (rawLockHeld === lockHeld) { return } - const timer = setTimeout(() => setLockHeld(true), 600) + const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS) return () => clearTimeout(timer) - }, [rawLockReason]) - const lockReason = lockHeld ? rawLockReason : null + }, [lockHeld, rawLockHeld]) + const lockReason = lockHeld ? (rawLockReason ?? 'waiting') : null return ( From d4dfc35ac4bdacc44fd98703a2cd8e880de0a5c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:23:29 -0700 Subject: [PATCH 03/46] fix(mobile): preserve multi-image chat attachments (#12639) * fix(mobile): preserve multi-image chat attachments * fix(mobile): use preferred array syntax * fix(mobile): harden multi-image attachment flow * fix(mobile): retain first-send image previews --- .../session/MobileNativeChatOverlay.test.ts | 1 + .../src/session/MobileNativeChatOverlay.tsx | 1 + mobile/src/session/MobileNativeChatView.tsx | 14 +- .../mobile-image-source-picker.test.ts | 68 +++++++- .../src/session/mobile-image-source-picker.ts | 88 +++++++---- ...mobile-native-chat-draft-reconcile.test.ts | 72 +++++++++ .../mobile-native-chat-draft-reconcile.ts | 146 ++++++++++++++++- ...obile-native-chat-image-attachment.test.ts | 104 ++++++++++-- .../mobile-native-chat-image-attachment.ts | 65 ++++++-- .../mobile-native-chat-image-send.test.ts | 12 +- .../mobile-native-chat-pending-echo.ts | 91 +++++++++++ .../mobile-native-chat-render-data.test.ts | 30 ++++ .../session/mobile-native-chat-render-data.ts | 30 +++- .../use-mobile-native-chat-controller.test.ts | 1 + .../use-mobile-native-chat-controller.ts | 3 + .../use-mobile-native-chat-drafts.test.ts | 72 ++++++++- .../session/use-mobile-native-chat-drafts.ts | 148 ++++++++++-------- ...bile-native-chat-image-attachments.test.ts | 52 +++--- ...se-mobile-native-chat-image-attachments.ts | 54 ++++--- ...tive-chat-image-transcript-markers.test.ts | 46 ++++++ .../native-chat-image-transcript-markers.ts | 65 ++++---- 21 files changed, 944 insertions(+), 219 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-draft-reconcile.test.ts create mode 100644 mobile/src/session/mobile-native-chat-pending-echo.ts diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 2680064c94b..c8e62c146cb 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -47,6 +47,7 @@ function overlayElement(tick: Tick): ReturnType { nativeChatStreamLive: tick.streamLive ?? false, nativeChatStreamScopeKey: tick.identity ?? 'tab-a', chatPending: [], + chatImagePreviewsByMessageId: {}, chatComposerText: '', setChatComposerText: vi.fn() } as unknown as MobileNativeChatController diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 4361c185d3c..699f8a1614b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -82,6 +82,7 @@ export function MobileNativeChatOverlay({ onLoadEarlier={session.loadEarlier} onSend={images.sendNativeChat} pending={controller.chatPending} + imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} composerText={controller.chatComposerText} onComposerTextChange={controller.setChatComposerText} onAttachImage={() => void images.attachImage('library')} diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 48a1fcd31d3..478d36267a7 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -61,6 +61,9 @@ type Props = { /** Optimistic queued sends (owned by the route so they survive view switches). */ /** Optimistic user echoes, including any ridden-along image preview URIs. */ pending: MobileNativeChatPendingItem[] + /** Local photo URIs retained when the authoritative transcript replaces an + * optimistic image bubble. */ + imagePreviewsByMessageId?: Record /** Controlled composer text (owned by the route so dictation can write to it). */ composerText: string onComposerTextChange: (text: string) => void @@ -125,6 +128,7 @@ export function MobileNativeChatView({ onLoadEarlier, onSend, pending, + imagePreviewsByMessageId, composerText, onComposerTextChange, onAttachImage, @@ -177,8 +181,14 @@ export function MobileNativeChatView({ // route-owned optimistic queued messages. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. const { data } = useMemo( - () => buildMobileNativeChatTransientData({ folded, streaming, pending }), - [folded, streaming, pending] + () => + buildMobileNativeChatTransientData({ + folded, + streaming, + pending, + imagePreviewsByMessageId + }), + [folded, streaming, pending, imagePreviewsByMessageId] ) // Follow the tail as the conversation grows and keep the newest message above diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts index fbdd9eb5dec..35e3cff3b60 100644 --- a/mobile/src/session/mobile-image-source-picker.test.ts +++ b/mobile/src/session/mobile-image-source-picker.test.ts @@ -12,13 +12,28 @@ vi.mock('expo-file-system', () => ({ File: vi.fn() })) -import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker' +import { + ImageLibraryPermissionError, + pickMobileImage, + pickMobileImages, + type PickedMobileImage +} from './mobile-image-source-picker' const granted = { granted: true } as Awaited< ReturnType > const denied = { granted: false } as typeof granted +async function collectImages( + images: AsyncIterable +): Promise { + const collected: PickedMobileImage[] = [] + for await (const image of images) { + collected.push(image) + } + return collected +} + function fileFactory( bytes: Uint8Array, options?: { fileSize?: number; handleSize?: number | null; readError?: Error } @@ -68,6 +83,57 @@ describe('pickMobileImage', () => { expect(file.close).toHaveBeenCalledTimes(1) }) + it('returns every selected library photo in order', async () => { + const bytesByUri = new Map([ + ['file:///a.jpg', new Uint8Array([1])], + ['file:///b.jpg', new Uint8Array([2])], + ['file:///c.jpg', new Uint8Array([3])] + ]) + const createFile = vi.fn((uri: string) => { + const bytes = bytesByUri.get(uri)! + let read = false + return { + size: bytes.length, + open: () => ({ + size: bytes.length, + readBytes: () => { + if (read) { + return new Uint8Array() + } + read = true + return bytes + }, + close: vi.fn() + }) + } + }) + const launchLibrary = vi.fn().mockResolvedValue({ + canceled: false, + assets: [...bytesByUri].map(([uri, bytes]) => ({ uri, fileSize: bytes.length })) + }) + + const result = await collectImages( + pickMobileImages('library', { + requestLibraryPermission: vi.fn().mockResolvedValue(granted), + launchLibrary, + createFile + }) + ) + + expect(result.map((image) => image.uri)).toEqual([ + 'file:///a.jpg', + 'file:///b.jpg', + 'file:///c.jpg' + ]) + expect(launchLibrary).toHaveBeenCalledWith( + expect.objectContaining({ + allowsMultipleSelection: true, + orderedSelection: true, + selectionLimit: 0 + }) + ) + }) + it('throws when photo library permission is denied', async () => { await expect( pickMobileImage('library', { diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts index bf9c529cc25..062085f7270 100644 --- a/mobile/src/session/mobile-image-source-picker.ts +++ b/mobile/src/session/mobile-image-source-picker.ts @@ -83,11 +83,12 @@ async function readUriAsBase64( } } -async function pickFromLibrary( +async function* pickFromLibrary( + multiple: boolean, requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync, launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const permission = await requestPermission() // Why: `granted` covers full + limited iOS access; only a hard denial blocks us. if (!permission.granted) { @@ -96,51 +97,84 @@ async function pickFromLibrary( const result = await launch({ mediaTypes: ['images'], base64: false, - allowsMultipleSelection: false, + allowsMultipleSelection: multiple, + ...(multiple ? { selectionLimit: 0, orderedSelection: true } : {}), quality: 1 }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null - if (!base64) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.fileSize, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) } } -async function pickFromFiles( +async function* pickFromFiles( + multiple: boolean, launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const result = await launch({ type: 'image/*', - multiple: false, + multiple, copyToCacheDirectory: true }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - if (!asset?.uri) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) - return base64 ? { base64, uri: asset.uri } : null +} + +type MobileImagePickerDeps = { + readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync + readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync + readonly launchFiles?: typeof DocumentPicker.getDocumentAsync + readonly createFile?: MobileImageFileFactory +} + +function pickMobileImagesWithMode( + source: MobileImageSource, + multiple: boolean, + deps?: MobileImagePickerDeps +): AsyncIterable { + if (source === 'library') { + return pickFromLibrary( + multiple, + deps?.requestLibraryPermission, + deps?.launchLibrary, + deps?.createFile + ) + } + return pickFromFiles(multiple, deps?.launchFiles, deps?.createFile) } export async function pickMobileImage( source: MobileImageSource, - deps?: { - readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync - readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync - readonly launchFiles?: typeof DocumentPicker.getDocumentAsync - readonly createFile?: MobileImageFileFactory - } + deps?: MobileImagePickerDeps ): Promise { - if (source === 'library') { - return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile) + for await (const image of pickMobileImagesWithMode(source, false, deps)) { + return image } - return pickFromFiles(deps?.launchFiles, deps?.createFile) + return null +} + +export function pickMobileImages( + source: MobileImageSource, + deps?: MobileImagePickerDeps +): AsyncIterable { + return pickMobileImagesWithMode(source, true, deps) } diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts new file mode 100644 index 00000000000..4c0cee804c1 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + findLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, + type PendingImagePreviewEcho +} from './mobile-native-chat-draft-reconcile' + +function userText(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function pending(id: string, images: string[], expectedOccurrence = 1): PendingImagePreviewEcho { + return { id, text: '', images, expectedOccurrence, baselineTailMessageId: null } +} + +describe('mobile native chat image preview reconciliation', () => { + it('keeps separate adjacent image-only sends independently reconcilable', () => { + const landed = findLandedImagePreviewEchoes( + [ + userText('source-a', '[Image: source: /tmp/a.png]'), + userText('source-b', '[Image: source: /tmp/b.png]') + ], + [pending('pending-a', ['file:///a.jpg']), pending('pending-b', ['file:///b.jpg'], 2)] + ) + + expect(landed).toEqual([ + { pendingId: 'pending-a', messageId: 'source-a', images: ['file:///a.jpg'] }, + { pendingId: 'pending-b', messageId: 'source-b', images: ['file:///b.jpg'] } + ]) + }) + + it('waits for a complete multi-image turn as transcript source frames stream in', () => { + const entry = pending('pending', ['file:///a.jpg', 'file:///b.jpg']) + const sourceA = userText('source-a', '[Image: source: /tmp/a.png]') + const sourceB = userText('source-b', '[Image: source: /tmp/b.png]') + + expect(findLandedImagePreviewEchoes([sourceA], [entry])).toEqual([]) + expect(findLandedImagePreviewEchoes([sourceA, sourceB], [entry])).toEqual([]) + expect( + findLandedImagePreviewEchoes( + [sourceA, sourceB, userText('prompt', '[Image #1] [Image #2]')], + [entry] + ) + ).toEqual([ + { + pendingId: 'pending', + messageId: 'prompt', + images: ['file:///a.jpg', 'file:///b.jpg'] + } + ]) + }) + + it('moves an early standalone preview to the later folded prompt id', () => { + const sessionKey = 'host\0worktree\0tab\0session' + const previous = { [sessionKey]: { source: ['file:///a.jpg'] } } + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', '[Image #1]') + ] + + expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({ + [sessionKey]: { prompt: ['file:///a.jpg'] } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 1af010ff503..360fd22587b 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -1,6 +1,7 @@ -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { isImageSourceUserTurn, + normalizeImageTranscriptMessages, stripImagePromptMarker } from './mobile-native-chat-image-transcript-markers' @@ -61,6 +62,149 @@ export function countImageSourceTurnsAfter( return count } +export type PendingImagePreviewEcho = { + id: string + text: string + images?: string[] + expectedOccurrence: number + baselineTailMessageId: string | null +} + +export type LandedImagePreviewEcho = { + pendingId: string + messageId: string + images: string[] +} + +const SENT_IMAGE_PREVIEW_LIMIT = 32 +const SENT_IMAGE_PREVIEW_SESSION_LIMIT = 8 + +export function mergeLandedImagePreviewEchoes( + previous: Record>, + sessionKey: string, + landed: readonly LandedImagePreviewEcho[] +): Record> { + const entries = Object.entries(previous[sessionKey] ?? {}) + for (const preview of landed) { + const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId) + if (existingIndex >= 0) { + entries.splice(existingIndex, 1) + } + entries.push([preview.messageId, preview.images]) + } + const next = { ...previous } + delete next[sessionKey] + next[sessionKey] = Object.fromEntries(entries.slice(-SENT_IMAGE_PREVIEW_LIMIT)) + for (const key of Object.keys(next).slice(0, -SENT_IMAGE_PREVIEW_SESSION_LIMIT)) { + delete next[key] + } + return next +} + +function imagePreviewReplacementMessageId( + messages: readonly NativeChatMessage[], + sourceIndex: number +): string | null { + const source = messages[sourceIndex] + if (!source || !isImageSourceUserTurn(source)) { + return null + } + let nextIndex = sourceIndex + 1 + while ( + messages[nextIndex]?.source === source.source && + isImageSourceUserTurn(messages[nextIndex]!) + ) { + nextIndex++ + } + const prompt = messages[nextIndex] + const firstText = prompt?.blocks.find((block) => block.type === 'text') + return prompt?.role === 'user' && + prompt.source === source.source && + firstText?.type === 'text' && + stripImagePromptMarker(firstText.text) !== firstText.text + ? prompt.id + : null +} + +/** Moves previews forward when a progressive source-only transcript frame later + * folds into the marker-prefixed prompt with a different authoritative id. */ +export function migrateImagePreviewMessageIds( + previous: Record>, + sessionKey: string, + messages: readonly NativeChatMessage[] +): Record> { + const sessionPreviews = previous[sessionKey] + if (!sessionPreviews) { + return previous + } + const messageIndexById = new Map(messages.map((message, index) => [message.id, index])) + let nextSession: Record | null = null + for (const [messageId, images] of Object.entries(sessionPreviews)) { + const sourceIndex = messageIndexById.get(messageId) + if (sourceIndex === undefined) { + continue + } + const replacementId = imagePreviewReplacementMessageId(messages, sourceIndex) + if (!replacementId) { + continue + } + nextSession ??= { ...sessionPreviews } + delete nextSession[messageId] + nextSession[replacementId] = [...(nextSession[replacementId] ?? []), ...images] + } + return nextSession ? { ...previous, [sessionKey]: nextSession } : previous +} + +/** Binds local preview URIs to the authoritative transcript turn that replaced + * the optimistic bubble. Host paths and marker-only Codex turns cannot render + * the phone-local photo without this handoff. */ +export function findLandedImagePreviewEchoes( + messages: readonly NativeChatMessage[], + entries: readonly PendingImagePreviewEcho[] +): LandedImagePreviewEcho[] { + const normalized = normalizeImageTranscriptMessages(messages) + const messageIndexById = new Map(normalized.map((message, index) => [message.id, index])) + const claimedMessageIds = new Set() + const landed: LandedImagePreviewEcho[] = [] + + for (const entry of entries) { + if (!entry.images?.length) { + continue + } + const targetText = entry.text.trim() + const candidates = normalized.filter((message) => { + if (message.role !== 'user') { + return false + } + if (targetText) { + return normalizedUserText(message) === targetText + } + const imageCount = message.blocks.filter(isImageRefBlock).length + return message.blocks.length === 0 || imageCount >= entry.images!.length + }) + const tailIndex = entry.baselineTailMessageId + ? messageIndexById.get(entry.baselineTailMessageId) + : -1 + const occurrenceIndex = Math.max(0, entry.expectedOccurrence - 1) + const candidate = targetText + ? candidates[occurrenceIndex] + : candidates.filter( + (message) => + tailIndex === undefined || (messageIndexById.get(message.id) ?? -1) > tailIndex + )[occurrenceIndex] + if ( + !candidate || + claimedMessageIds.has(candidate.id) || + (tailIndex !== undefined && (messageIndexById.get(candidate.id) ?? -1) <= tailIndex) + ) { + continue + } + claimedMessageIds.add(candidate.id) + landed.push({ pendingId: entry.id, messageId: candidate.id, images: entry.images }) + } + return landed +} + export function findLandedUnconfirmedSends( messages: readonly NativeChatMessage[], entries: readonly UnconfirmedSend[] diff --git a/mobile/src/session/mobile-native-chat-image-attachment.test.ts b/mobile/src/session/mobile-native-chat-image-attachment.test.ts index 57c3f67bd1c..e61116f3caa 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.test.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' -import { uploadMobileNativeChatImage } from './mobile-native-chat-image-attachment' +import { uploadMobileNativeChatImages } from './mobile-native-chat-image-attachment' function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } } @@ -16,6 +16,10 @@ function methodNotFound(id: string): RpcResponse { } } +function failed(id: string, message: string): RpcResponse { + return { id, ok: false, error: { code: 'failed', message }, _meta: { runtimeId: 'r' } } +} + function clientWithResponses(responses: RpcResponse[]): Pick & { calls: { method: string; params: unknown }[] } { @@ -33,67 +37,137 @@ function clientWithResponses(responses: RpcResponse[]): Pick { +describe('uploadMobileNativeChatImages', () => { it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => { const client = clientWithResponses([ methodNotFound('start'), ok('save', '/tmp/orca-attach.png') ]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => 'conn-7', - pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA', uri: 'file:///photo.jpg' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'AAAA', uri: 'file:///photo.jpg' }]) }) - expect(result).toEqual({ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }) + expect(result).toEqual([{ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }]) // Native chat defers the paste to submit — nothing is sent to the terminal here. expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile') expect(saveCall?.params).toMatchObject({ connectionId: 'conn-7' }) }) + it('uploads all three selected images in picker order', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + ok('save-b', '/tmp/b.png'), + methodNotFound('start-c'), + ok('save-c', '/tmp/c.png') + ]) + + const order: string[] = [] + async function* pickImages() { + for (const image of [ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' }, + { base64: 'CCCC', uri: 'file:///c.jpg' } + ]) { + order.push(`read:${image.uri}`) + yield image + } + } + const result = await uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => 'conn-7', + pickImages, + onImageUploaded: (image) => order.push(`uploaded:${image.previewUri}`) + }) + + expect(result).toEqual([ + { path: '/tmp/a.png', previewUri: 'file:///a.jpg' }, + { path: '/tmp/b.png', previewUri: 'file:///b.jpg' }, + { path: '/tmp/c.png', previewUri: 'file:///c.jpg' } + ]) + expect(order).toEqual([ + 'read:file:///a.jpg', + 'uploaded:file:///a.jpg', + 'read:file:///b.jpg', + 'uploaded:file:///b.jpg', + 'read:file:///c.jpg', + 'uploaded:file:///c.jpg' + ]) + }) + it('returns null when the picker is cancelled and uploads nothing', async () => { const client = clientWithResponses([]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null) + pickImages: vi.fn().mockResolvedValue([]) }) - expect(result).toBeNull() + expect(result).toEqual([]) expect(client.calls).toEqual([]) }) + it('reports completed uploads before a later image fails', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + failed('save-b', 'upload failed') + ]) + const onImageUploaded = vi.fn() + + await expect( + uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => null, + pickImages: vi.fn().mockResolvedValue([ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' } + ]), + onImageUploaded + }) + ).rejects.toThrow('upload failed') + expect(onImageUploaded).toHaveBeenCalledOnce() + expect(onImageUploaded).toHaveBeenCalledWith({ + path: '/tmp/a.png', + previewUri: 'file:///a.jpg' + }) + }) + it('falls back to an inline data uri for the preview when the picker omits a uri', async () => { const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/x.png')]) - const result = await uploadMobileNativeChatImage('files', { + const result = await uploadMobileNativeChatImages('files', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'BBBB' }]) }) - expect(result).toEqual({ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }) + expect(result).toEqual([{ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }]) }) it('signals upload start only after a real image is picked', async () => { const onUploadStart = vi.fn() const cancelledClient = clientWithResponses([]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client: cancelledClient, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null), + pickImages: vi.fn().mockResolvedValue([]), onUploadStart }) expect(onUploadStart).not.toHaveBeenCalled() const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/y.png')]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC', uri: 'file:///y.jpg' }), + pickImages: vi.fn().mockResolvedValue([{ base64: 'CCCC', uri: 'file:///y.jpg' }]), onUploadStart }) expect(onUploadStart).toHaveBeenCalledTimes(1) diff --git a/mobile/src/session/mobile-native-chat-image-attachment.ts b/mobile/src/session/mobile-native-chat-image-attachment.ts index f81b9d2ad98..fc9a962bacf 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.ts @@ -13,34 +13,67 @@ export type PendingNativeChatImage = { readonly previewUri: string } -export type UploadNativeChatImageDeps = { +export function appendPendingNativeChatImages( + current: readonly PendingNativeChatImage[], + uploaded: readonly Omit[], + idCounter: { current: number } +): PendingNativeChatImage[] { + return [ + ...current, + ...uploaded.map((image) => { + idCounter.current += 1 + return { id: `img-${idCounter.current}`, ...image } + }) + ] +} + +export type UploadNativeChatImagesDeps = { readonly client: Pick readonly getConnectionId: () => Promise // Injected so this module stays free of expo/react-native imports (unit-testable). - readonly pickImage: (source: MobileImageSource) => Promise + readonly pickImages: ( + source: MobileImageSource + ) => + | Iterable + | AsyncIterable + | Promise | AsyncIterable> // Fired once the user has picked an image and the host upload is about to start — // lets the UI show the attach spinner only for the transfer, not the picker. readonly onUploadStart?: () => void + /** Retains each completed upload if a later image in the same selection fails. */ + readonly onImageUploaded?: (image: Omit) => void } /** Picks an image and uploads it to the host, returning the host path + a local * preview URI — but does NOT paste it into the terminal. Unlike the terminal * attach flow, native chat holds the image as a composer chip and rides it along * on submit (desktop parity), so the chip and the agent input never diverge. - * Returns null when the user cancels the picker. */ -export async function uploadMobileNativeChatImage( + * Returns an empty array when the user cancels the picker. */ +export async function uploadMobileNativeChatImages( source: MobileImageSource, - { client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps -): Promise | null> { - const picked = await pickImage(source) - if (!picked) { - return null + { + client, + getConnectionId, + pickImages, + onUploadStart, + onImageUploaded + }: UploadNativeChatImagesDeps +): Promise[]> { + const picked = await pickImages(source) + const uploaded: Omit[] = [] + let connectionId: string | null = null + for await (const image of picked) { + if (uploaded.length === 0) { + onUploadStart?.() + connectionId = await getConnectionId() + } + const path = await saveMobileClipboardImageAsTempFile(client, image.base64, { connectionId }) + // Prefer the picker's local URI for the thumbnail; fall back to an inline data + // URI when the source omitted one (RN renders both). + const previewUri = image.uri ?? `data:image/png;base64,${image.base64}` + const result = { path, previewUri } + uploaded.push(result) + onImageUploaded?.(result) } - onUploadStart?.() - const connectionId = await getConnectionId() - const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId }) - // Prefer the picker's local URI for the thumbnail; fall back to an inline data - // URI when the source omitted one (RN renders both). - const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}` - return { path, previewUri } + return uploaded } diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index 46ac4730f2e..cf1c59adf0f 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -27,17 +27,22 @@ function clientWithResponses(responses: RpcResponse[]): Pick { it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => { - const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)]) + const client = clientWithResponses([ + sendResult(true), + sendResult(true), + sendResult(true), + sendResult(true) + ]) const ok = await pasteMobileNativeChatImagePaths({ client, terminal: 'term-1', deviceToken: 'device-9', - imagePaths: ['/tmp/a.png', '/tmp/b.png'] + imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'] }) expect(ok).toBe(true) - expect(client.calls).toHaveLength(3) + expect(client.calls).toHaveLength(4) // Leading Ctrl+U clears any stale input so a retry can't duplicate the image. expect(client.calls[0]).toEqual({ method: 'terminal.send', @@ -50,6 +55,7 @@ describe('pasteMobileNativeChatImagePaths', () => { }) expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~') expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~') + expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~') }) it('stops and reports failure as soon as a paste is rejected', async () => { diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts new file mode 100644 index 00000000000..b18136a9d47 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -0,0 +1,91 @@ +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number + /** Local preview URIs carried by the send for its optimistic echo. */ + images?: string[] + baselineTailMessageId: string | null +} + +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number + baselineTailMessageId: string | null +} + +type PendingByKey = Record + +export function combineMobileNativeChatPending( + session: MobileNativeChatPendingMessage[], + waiting: readonly MobileNativeChatPendingMessage[] +): MobileNativeChatPendingMessage[] { + if (waiting.length === 0) { + return session + } + const sessionIds = new Set(session.map((item) => item.id)) + return [...session, ...waiting.filter((item) => !sessionIds.has(item.id))] +} + +export function appendMobileNativeChatPending( + previous: PendingByKey, + key: string, + id: string, + origin: MobileNativeChatSendOrigin, + text: string, + images?: string[] +): PendingByKey { + const current = previous[key] ?? [] + const earlierOutstanding = current.filter( + (pending) => + pending.text.trim() === origin.normalizedText && + pending.expectedOccurrence > origin.baselineOccurrences + ).length + const expectedImageEchoOrdinal = + current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1 + return { + ...previous, + [key]: [ + ...current, + { + id, + text, + expectedOccurrence: + origin.normalizedText === '' + ? expectedImageEchoOrdinal + : origin.baselineOccurrences + earlierOutstanding + 1, + baselineTailMessageId: origin.baselineTailMessageId, + ...(images?.length ? { images } : {}) + } + ] + } +} + +export function mergeWaitingSessionPending( + previous: PendingByKey, + sessionKey: string, + waiting: readonly MobileNativeChatPendingMessage[] +): PendingByKey { + const current = previous[sessionKey] ?? [] + const currentIds = new Set(current.map((item) => item.id)) + const moved = waiting.filter((item) => !currentIds.has(item.id)) + return moved.length > 0 ? { ...previous, [sessionKey]: [...current, ...moved] } : previous +} + +export function removeWaitingSessionPending( + previous: PendingByKey, + draftKey: string, + movedIds: ReadonlySet +): PendingByKey { + const remaining = (previous[draftKey] ?? []).filter((item) => !movedIds.has(item.id)) + if (remaining.length > 0) { + return { ...previous, [draftKey]: remaining } + } + if (!(draftKey in previous)) { + return previous + } + const next = { ...previous } + delete next[draftKey] + return next +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index b4a273d31ca..c67257ec24d 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -116,6 +116,36 @@ describe('buildMobileNativeChatTransientData', () => { expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) }) + it('keeps the phone-local image visible when the transcript replaces its optimistic echo', () => { + const folded = foldMobileNativeChatMessages([ + user('source', '[Image: source: /tmp/a.png]'), + user('prompt', '[Image #1] look at this') + ]) + const result = buildMobileNativeChatTransientData({ + folded, + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data).toHaveLength(1) + expect(result.data[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' }, + { type: 'text', text: 'look at this' } + ]) + }) + + it('restores the local preview onto a marker-only transcript turn', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }]) + }) + it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { // Whether text streams at all is the gate's call // (`mobile-native-chat-streaming-gate.test.ts`); this only places it. diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index e11b9ac56f4..b3d4ba1c0fd 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -3,7 +3,7 @@ import { formatNativeChatEmptyStateCopy, type NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' import { stripNoiseMessages } from './mobile-native-chat-noise' @@ -55,15 +55,37 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat export function buildMobileNativeChatTransientData({ folded, streaming, - pending + pending, + imagePreviewsByMessageId }: { folded: NativeChatMessage[] /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ streaming: string | null pending: MobileNativeChatPendingItem[] + imagePreviewsByMessageId?: Record }): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const renderedFolded = folded.map((message) => { + const previews = imagePreviewsByMessageId?.[message.id] + if (message.role !== 'user' || !previews?.length) { + return message + } + let previewIndex = 0 + const blocks = message.blocks.map((block) => { + if (!isImageRefBlock(block)) { + return block + } + const url = previews[previewIndex] + previewIndex += 1 + return url ? { ...block, url } : block + }) + while (previewIndex < previews.length) { + blocks.push({ type: 'image-ref', url: previews[previewIndex] }) + previewIndex += 1 + } + return { ...message, blocks } + }) const data: NativeChatMessage[] = [ - ...folded, + ...renderedFolded, ...(streaming ? [ { @@ -88,5 +110,5 @@ export function buildMobileNativeChatTransientData({ source: 'transcript' as const })) ] - return { folded, streaming, data } + return { folded: renderedFolded, streaming, data } } diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index a1ed942316f..a9cd1201639 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -40,6 +40,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ composerText: '', setComposerText: vi.fn(), pending: [], + imagePreviewsByMessageId: {}, captureSendOrigin, readSeededLaunchDraft: () => null, readSeededLaunchDraftSeed: () => null, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 49d75409c05..5ed98925e9c 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -50,6 +50,7 @@ export type MobileNativeChatController = { chatComposerText: string setChatComposerText: Dispatch> chatPending: MobileNativeChatPendingMessage[] + chatImagePreviewsByMessageId: Record nativeChatSession: ReturnType nativeChatAgentWorking: boolean nativeChatStreamingText?: string @@ -160,6 +161,7 @@ export function useMobileNativeChatController(args: { composerText: chatComposerText, setComposerText: setChatComposerText, pending: chatPending, + imagePreviewsByMessageId: chatImagePreviewsByMessageId, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, @@ -326,6 +328,7 @@ export function useMobileNativeChatController(args: { chatComposerText, setChatComposerText, chatPending, + chatImagePreviewsByMessageId, nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index bf3f94bd239..154ef3fb870 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -269,6 +269,7 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it("keeps an image-only echo when an unrelated text send's echo lands", async () => { @@ -345,6 +346,29 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] }) + }) + + it('hands a marker-only image preview to the authoritative user bubble', async () => { + await mount('a') + const origin = state?.captureSendOrigin('') + act(() => { + if (origin) { + state?.acceptSend(origin, '', ['file:///a.jpg']) + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('u1', '[Image #1]')] + }) + ) + ) + + expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it('does not reconcile a repeated send against an older identical turn', async () => { @@ -722,21 +746,59 @@ describe('useMobileNativeChatDrafts', () => { } }) - it('accepts and clears the first send before a provider session id exists', async () => { + it('preserves first-send images through session assignment and transcript replacement', async () => { await mount('a') await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) - act(() => state?.setComposerText('start the session')) + const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + act(() => state?.setComposerText('look')) - const origin = state?.captureSendOrigin('start the session') + const origin = state?.captureSendOrigin('look') expect(origin).toMatchObject({ pendingKey: null }) act(() => { if (origin) { - state?.clearDraftForSend(origin, 'start the session') - state?.acceptSend(origin, 'start the session') + state?.clearDraftForSend(origin, 'look') + state?.acceptSend(origin, 'look', images) } }) expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', sessionId: 'assigned' })) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]') + ] + }) + ) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]'), + userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look') + ] + }) + ) + ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images }) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a43bc8b286f..9da4dea6a9b 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -3,35 +3,29 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, countUserTextOccurrences, + findLandedImagePreviewEchoes, findLandedUnconfirmedSends, + mergeLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, normalizedUserText, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' +import { + appendMobileNativeChatPending, + combineMobileNativeChatPending, + mergeWaitingSessionPending, + removeWaitingSessionPending, + type MobileNativeChatPendingMessage, + type MobileNativeChatSendOrigin +} from './mobile-native-chat-pending-echo' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' -export type MobileNativeChatPendingMessage = { - id: string - text: string - expectedOccurrence: number - /** Local preview URIs of images ridden along on the send, rendered as thumbnails - * on the echo bubble so the sent photo shows before the transcript catches up. */ - images?: string[] - /** Transcript tail when sent — an image-only echo (no text to match) reconciles - * against new `[Image: source: …]` echo turns after this id, so pagination, - * agent replies, and unrelated text echoes can't clear it early. */ - baselineTailMessageId: string | null -} -export type MobileNativeChatSendOrigin = { - draftKey: string - pendingKey: string | null - normalizedText: string - baselineOccurrences: number - baselineTailMessageId: string | null -} +export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] +const NO_IMAGE_PREVIEWS: Record = {} // How long an ack-lost send waits for its transcript echo before the UI surfaces // that delivery remains unconfirmed. @@ -57,6 +51,9 @@ export function useMobileNativeChatDrafts(args: { composerText: string setComposerText: Dispatch> pending: MobileNativeChatPendingMessage[] + /** Phone-local previews rebound to the transcript message that replaced the + * optimistic echo, keyed by authoritative message id. */ + imagePreviewsByMessageId: Record captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null /** Launch-context text still believed to be parked on the agent's TUI input * line, or null once it has been declined or retired. Send paths size their @@ -91,6 +88,12 @@ export function useMobileNativeChatDrafts(args: { const [pendingBySession, setPendingBySession] = useState< Record >({}) + const [pendingWaitingForSession, setPendingWaitingForSession] = useState< + Record + >({}) + const [imagePreviewsBySession, setImagePreviewsBySession] = useState< + Record> + >({}) const pendingCounterRef = useRef(0) const messagesRef = useRef(messages) messagesRef.current = messages @@ -162,41 +165,21 @@ export function useMobileNativeChatDrafts(args: { const acceptSend = useCallback( (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { - // Why: the first prompt can be sent before the provider reports a session - // id; wait for an id before keying an optimistic echo. - if (!origin.pendingKey) { + if (!origin.pendingKey && !images?.length) { return } - const pendingKey = origin.pendingKey pendingCounterRef.current += 1 - setPendingBySession((previous) => { - const current = previous[pendingKey] ?? NO_PENDING_MESSAGES - const earlierOutstanding = current.filter( - (pending) => - pending.text.trim() === origin.normalizedText && - pending.expectedOccurrence > origin.baselineOccurrences - ).length - // An empty-text send reconciles by image-echo ordinal: every outstanding - // send's ridden-along images echo as `[Image: source: …]` turns after - // this send's baseline tail, ahead of this send's own echo. - const expectedImageEchoOrdinal = - current.reduce( - (sum, pending) => - sum + (pending.images?.length ?? (pending.text.trim() === '' ? 1 : 0)), - 0 - ) + 1 - const pending: MobileNativeChatPendingMessage = { - id: `pending-${pendingCounterRef.current}`, - text, - expectedOccurrence: - origin.normalizedText === '' - ? expectedImageEchoOrdinal - : origin.baselineOccurrences + earlierOutstanding + 1, - baselineTailMessageId: origin.baselineTailMessageId, - ...(images && images.length > 0 ? { images } : {}) - } - return { ...previous, [pendingKey]: [...current, pending] } - }) + const id = `pending-${pendingCounterRef.current}` + const key = origin.pendingKey + if (key) { + setPendingBySession((previous) => + appendMobileNativeChatPending(previous, key, id, origin, text, images) + ) + } else { + setPendingWaitingForSession((previous) => + appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) + ) + } }, [] ) @@ -255,9 +238,7 @@ export function useMobileNativeChatDrafts(args: { const landedSet = new Set(landed) unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) for (const entry of landed) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } }, [messages, draftKey, pendingKey]) @@ -266,21 +247,49 @@ export function useMobileNativeChatDrafts(args: { return () => { mountedRef.current = false for (const entry of unconfirmedRef.current) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } unconfirmedRef.current = [] } }, []) - const pending = pendingKey - ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + const waitingForSession = draftKey + ? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES useEffect(() => { - if (!pendingKey || pending.length === 0) { + if (!draftKey || !pendingKey || waitingForSession.length === 0) { return } + const movedIds = new Set(waitingForSession.map((item) => item.id)) + setPendingBySession((previous) => + mergeWaitingSessionPending(previous, pendingKey, waitingForSession) + ) + setPendingWaitingForSession((previous) => + removeWaitingSessionPending(previous, draftKey, movedIds) + ) + }, [draftKey, pendingKey, waitingForSession]) + + const sessionPending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + const pending = combineMobileNativeChatPending(sessionPending, waitingForSession) + useEffect(() => { + if (!pendingKey) { + return + } + setImagePreviewsBySession((previous) => + migrateImagePreviewMessageIds(previous, pendingKey, messages) + ) + if (pending.length === 0) { + return + } + const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending) + const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId)) + if (landedImagePreviews.length > 0) { + setImagePreviewsBySession((previous) => + mergeLandedImagePreviewEchoes(previous, pendingKey, landedImagePreviews) + ) + } setPendingBySession((previous) => { const current = previous[pendingKey] ?? [] const landedCounts = new Map() @@ -297,12 +306,20 @@ export function useMobileNativeChatDrafts(args: { // baseline tail — text echoes are excluded so an unrelated outstanding // text send cannot clear it. Ordinal-vs-count stays stable when the effect // re-runs on the shrunken list, and ignores paginated-in history. - const next = current.filter((item) => - item.text.trim() === '' + const next = current.filter((item) => { + if (landedImagePendingIds.has(item.id)) { + return false + } + // Image echoes hand their local URIs to the authoritative message above; + // never drop them through the text-only fallback before that handoff. + if (item.images?.length) { + return true + } + return item.text.trim() === '' ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < - item.expectedOccurrence + item.expectedOccurrence : (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence - ) + }) if (next.length === current.length) { return previous } @@ -319,6 +336,9 @@ export function useMobileNativeChatDrafts(args: { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, pending, + imagePreviewsByMessageId: pendingKey + ? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS) + : NO_IMAGE_PREVIEWS, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 13cb5d96c48..a57e1c7520c 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -11,13 +11,13 @@ import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-im // Fully stub the picker so the real expo/react-native chain never loads under // the vitest transform (react-native ships Flow syntax rolldown can't parse). vi.mock('./mobile-image-source-picker', () => ({ - pickMobileImage: vi.fn(), + pickMobileImages: vi.fn(), ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {} })) -import { pickMobileImage } from './mobile-image-source-picker' +import { pickMobileImages } from './mobile-image-source-picker' -const pick = vi.mocked(pickMobileImage) +const pick = vi.mocked(pickMobileImages) function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'r' } } @@ -124,7 +124,7 @@ describe('useMobileNativeChatImageAttachments', () => { } it('adds an uploaded image as a chip without pasting to the terminal', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) mount( baseArgs({ @@ -145,7 +145,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('rides pending images along on send: pastes the path, settles, then delegates the text', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -208,7 +208,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => { // A single Ctrl+U kills only the last line, so the draft's earlier lines // would survive the clear and ride along with the image as prompt body. - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -242,7 +242,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('spends one budget across the image paste and the text body that follows', async () => { vi.useFakeTimers() try { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -280,7 +280,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -321,7 +321,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and does not submit when the image paste is rejected', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -345,7 +345,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and reports failure when the paste transport throws', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) // No terminal.send responses queued: the clear write throws (dropped transport). const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') @@ -365,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const onSendError = vi.fn() @@ -387,7 +387,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('scopes chips to the tab that attached them', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const args = baseArgs({ client: client as unknown as RpcClient, baseSend }) @@ -426,7 +426,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let firstAttach: Promise | null = null await act(async () => { firstAttach = hook!.attachImage('library') @@ -438,7 +438,7 @@ describe('useMobileNativeChatImageAttachments', () => { expect(hook!.isAttaching).toBe(true) // A concurrent cancelled pick — its finally must leave the counter alone. - pick.mockResolvedValue(null) + pick.mockResolvedValue([]) await act(async () => { await hook!.attachImage('library') }) @@ -454,7 +454,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('clears only the chips that were sent, keeping one attached mid-send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), // first attach @@ -488,7 +488,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(releaseSettle).not.toBeNull() - pick.mockResolvedValue({ base64: 'BBBB', uri: 'file:///b.jpg' }) + pick.mockResolvedValue([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) await act(async () => { await hook!.attachImage('library') }) @@ -511,7 +511,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('aborts the send when the active terminal changes during the settle window', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -560,7 +560,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('leads the next text-only send with Ctrl+U after a failed paste, even with the chip removed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -596,7 +596,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -634,7 +634,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('still heals after the session screen unmounts and remounts (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -688,7 +688,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('retains the stale marker when a rejected healing clear blocks text-only send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -730,7 +730,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('does not reroute text when the active terminal changes during a healing clear', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let releaseClear: ((response: RpcResponse) => void) | null = null const deferredClear = new Promise((resolve) => { releaseClear = resolve @@ -774,7 +774,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('defers the heal instead of burning a rejected clear while the lease is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -818,8 +818,8 @@ describe('useMobileNativeChatImageAttachments', () => { it('heals rejected image submits independently across terminals', async () => { pick - .mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' }) - .mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' }) + .mockResolvedValueOnce([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) + .mockResolvedValueOnce([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) const client = makeClient([ methodNotFound('start-a'), ok('save-a', '/tmp/a.png'), @@ -896,7 +896,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let attach: Promise | null = null await act(async () => { attach = hook!.attachImage('library') diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 38f9e462e46..dbcb97df527 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -5,11 +5,12 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { ImageLibraryPermissionError, - pickMobileImage, + pickMobileImages, type MobileImageSource } from './mobile-image-source-picker' import { - uploadMobileNativeChatImage, + appendPendingNativeChatImages, + uploadMobileNativeChatImages, type PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { @@ -82,10 +83,6 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - const NO_ATTACHMENTS: PendingNativeChatImage[] = [] function withScopeAttachments( @@ -146,48 +143,53 @@ export function useMobileNativeChatImageAttachments({ // pick or pre-upload error never ran `onUploadStart`, so decrementing the // shared counter would clear a concurrent upload's in-flight flag early. let started = false + const uploadedImages: Omit[] = [] + let uploadError: unknown = null try { - const uploaded = await uploadMobileNativeChatImage(source, { + await uploadMobileNativeChatImages(source, { client, getConnectionId: getActiveWorktreeConnectionId, - pickImage: pickMobileImage, + pickImages: pickMobileImages, + onImageUploaded: (image) => uploadedImages.push(image), onUploadStart: () => { started = true attachingCount.current += 1 setIsAttaching(true) } }) - // Cancelled picker: no error, no toast. - if (!uploaded) { - return - } - idCounter.current += 1 - const chip = { id: `img-${idCounter.current}`, ...uploaded } - setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] })) - onAttachSuccess?.() } catch (error) { + uploadError = error + } finally { + if (started) { + attachingCount.current -= 1 + if (attachingCount.current === 0) { + setIsAttaching(false) + } + } + } + if (uploadedImages.length > 0) { + setAttachmentsByScope((prev) => ({ + ...prev, + [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) + })) + onAttachSuccess?.() + } + if (uploadError !== null) { + const message = uploadError instanceof Error ? uploadError.message : String(uploadError) onError?.() if (connStateRef.current !== 'connected') { showToast('Attach failed (disconnected)', 1500) return } - if (error instanceof ImageLibraryPermissionError) { + if (uploadError instanceof ImageLibraryPermissionError) { showToast('Photo permission denied', 1500) return } - if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { + if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { showToast('Image too large to attach', 1500) return } showToast('Attach failed', 1500) - } finally { - if (started) { - attachingCount.current -= 1 - if (attachingCount.current <= 0) { - attachingCount.current = 0 - setIsAttaching(false) - } - } } }, [ diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index 58d6d0d16c5..e4856605a74 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -35,6 +35,52 @@ describe('normalizeImageTranscriptMessages', () => { ]) }) + it('folds every source and strips every prompt marker for a multi-image send', () => { + const out = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/a.png]'), + userText('b', '[Image: source: /tmp/b.png]'), + userText('c', '[Image: source: /tmp/c.png]'), + userText('prompt', '[Image #1] [Image #2] [Image #3] compare these') + ]) + + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ id: 'prompt' }) + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'image-ref', path: '/tmp/b.png' }, + { type: 'image-ref', path: '/tmp/c.png' }, + { type: 'text', text: 'compare these' } + ]) + }) + + it('keeps all image refs when a multi-image send has no caption', () => { + const out = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/a.png]'), + userText('b', '[Image: source: /tmp/b.png]'), + userText('prompt', '[Image #1] [Image #2]') + ]) + + expect(out).toHaveLength(1) + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'image-ref', path: '/tmp/b.png' } + ]) + }) + + it('preserves adjacent standalone image turns without a prompt marker', () => { + const out = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/a.png]'), + userText('b', '[Image: source: /tmp/b.png]') + ]) + + expect(out).toHaveLength(2) + expect(out.map((message) => message.id)).toEqual(['a', 'b']) + expect(out.map((message) => message.blocks)).toEqual([ + [{ type: 'image-ref', path: '/tmp/a.png' }], + [{ type: 'image-ref', path: '/tmp/b.png' }] + ]) + }) + it('leaves ordinary user text untouched', () => { const out = normalizeImageTranscriptMessages([userText('a', 'how about this')]) expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'how about this' }]) diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index 3f8f90f8cdb..9643cf518e0 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -1,7 +1,7 @@ import { isTextBlock, type NativeChatBlock, type NativeChatMessage } from './native-chat-types' const IMAGE_SOURCE_MARKER = /^\[Image:\s*source:\s*(.+?)\]\s*$/ -const IMAGE_PROMPT_MARKER = /^\[Image #\d+\]\s*/ +const IMAGE_PROMPT_MARKERS = /^(?:\[Image #\d+\]\s*)+/ function soleText(message: NativeChatMessage): string | null { return message.blocks.length === 1 && isTextBlock(message.blocks[0]) @@ -14,10 +14,12 @@ export function imageSourcePathFromText(text: string): string | null { } export function stripImagePromptMarker(text: string): string { - return text.replace(IMAGE_PROMPT_MARKER, '') + return text.replace(IMAGE_PROMPT_MARKERS, '') } -function stripFirstImagePromptMarker(blocks: readonly NativeChatBlock[]): NativeChatBlock[] { +function stripImagePromptMarkersFromFirstText( + blocks: readonly NativeChatBlock[] +): NativeChatBlock[] { let stripped = false const next: NativeChatBlock[] = [] for (const block of blocks) { @@ -36,13 +38,11 @@ function stripFirstImagePromptMarker(blocks: readonly NativeChatBlock[]): Native function imagePromptMarkerStartsMessage(message: NativeChatMessage): boolean { const firstText = message.blocks.find(isTextBlock) - return firstText ? IMAGE_PROMPT_MARKER.test(firstText.text) : false + return firstText ? IMAGE_PROMPT_MARKERS.test(firstText.text) : false } -/** Claude records an attached image as two user transcript turns: - * `[Image: source: /path]` and then `[Image #1] prompt`. Merge them back into - * one native turn so the UI keeps the same chip+text shape as the optimistic - * send and does not show raw TUI marker text after a view remount. */ +/** Claude records image paths as source turns followed by one marker-prefixed + * prompt. Merge the whole run back into one native user turn. */ export function normalizeImageTranscriptMessages( messages: readonly NativeChatMessage[] ): NativeChatMessage[] { @@ -54,27 +54,34 @@ export function normalizeImageTranscriptMessages( continue } const imagePath = imageSourcePathFromText(soleText(message) ?? '') - const next = messages[index + 1] - if ( - imagePath && - next?.role === 'user' && - next.source === message.source && - imagePromptMarkerStartsMessage(next) - ) { - normalized.push({ - ...next, - blocks: [ - { type: 'image-ref', path: imagePath }, - ...stripFirstImagePromptMarker(next.blocks) - ] - }) - index += 1 - continue - } - // A lone `[Image: source: /path]` turn (no following `[Image #1]` prompt — - // e.g. an image sent with no caption) still renders as an image chip rather - // than the raw marker text. if (imagePath) { + const imagePaths = [imagePath] + let nextIndex = index + 1 + while (nextIndex < messages.length) { + const candidate = messages[nextIndex]! + const candidatePath = imageSourcePathFromText(soleText(candidate) ?? '') + if (candidate.role !== 'user' || candidate.source !== message.source || !candidatePath) { + break + } + imagePaths.push(candidatePath) + nextIndex += 1 + } + const prompt = messages[nextIndex] + if ( + prompt?.role === 'user' && + prompt.source === message.source && + imagePromptMarkerStartsMessage(prompt) + ) { + normalized.push({ + ...prompt, + blocks: [ + ...imagePaths.map((path) => ({ type: 'image-ref' as const, path })), + ...stripImagePromptMarkersFromFirstText(prompt.blocks) + ] + }) + index = nextIndex + continue + } normalized.push({ ...message, blocks: [{ type: 'image-ref', path: imagePath }] @@ -83,7 +90,7 @@ export function normalizeImageTranscriptMessages( } normalized.push({ ...message, - blocks: stripFirstImagePromptMarker(message.blocks) + blocks: stripImagePromptMarkersFromFirstText(message.blocks) }) } return normalized From 74ac7049ecfa244e4f0384ccfb555036163033c1 Mon Sep 17 00:00:00 2001 From: Kyou <1129057944@qq.com> Date: Thu, 6 Aug 2026 04:31:26 +0800 Subject: [PATCH 04/46] fix(windows): make managed grok-hook.cmd safe when GROK_HOME is unset (#11782) * fix(windows): make managed grok-hook.cmd safe when GROK_HOME is unset Fixes #9358 and #9941. cmd.exe expands %VAR:~n,m% at parse time. When GROK_HOME is unset (default outside Orca terminals), the generated length/trailing-backslash guards became a syntax error and every Grok hook event failed with exit 255. - Skip substring work when GROK_HOME is undefined (if defined + goto) - Replace if "%x:~-1%"=="\" (itself a quote-parser bug) with findstr - Extract Windows script builder; add template + spawn tests * fix(windows): harden grok-hook GROK_HOME guards and tests Address review on #11782: - Inject grokHome via buildWindowsAgentHookPostCommand extra form lines (no fragile string replace of the shared payload line) - Spawn tests delete GROK_HOME and keep PORT/TOKEN/PANE_KEY set so the GROK_HOME path actually runs before curl * fix(windows): cover Grok hook home boundaries --------- Co-authored-by: OrcaWin --- config/tsconfig.cli.json | 1 + src/main/agent-hooks/installer-utils.ts | 10 +- src/main/grok/hook-service.test.ts | 187 +++++++++++++++++++++- src/main/grok/hook-service.ts | 32 +--- src/main/grok/windows-grok-hook-script.ts | 45 ++++++ 5 files changed, 243 insertions(+), 32 deletions(-) create mode 100644 src/main/grok/windows-grok-hook-script.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f4247281300..3e0e59381a4 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -63,6 +63,7 @@ "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", "../src/main/grok/hook-service.ts", + "../src/main/grok/windows-grok-hook-script.ts", "../src/main/devin/hook-settings.ts", "../src/main/devin/hook-service.ts", "../src/main/devin/hook-config-json.ts", diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index e0688b44345..a6d5d61fd41 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -161,7 +161,14 @@ export function wrapWindowsGitBashHookCommand(scriptPath: string): string { : wrapWindowsHookCommand(scriptPath) } -export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string { +/** + * Extra form lines inserted before the final `payload@-` line (each should end with ` ^`). + * Used by Grok to attach `grokHome` without fragile string replace on the shared template. + */ +export function buildWindowsAgentHookPostCommand( + source: AgentHookSource, + extraFormLines: readonly string[] = [] +): string { // Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path. // Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads. return [ @@ -175,6 +182,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin ' --data-urlencode "worktreeId=%ORCA_WORKTREE_ID%" ^', ' --data-urlencode "env=%ORCA_AGENT_HOOK_ENV%" ^', ' --data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%" ^', + ...extraFormLines, ' --data-urlencode "payload@-" >nul 2>nul' ].join('\r\n') } diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 02810726347..069b3b954be 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'node:child_process' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -16,12 +18,86 @@ vi.mock('os', async () => { }) import { getGrokToolEventMatcherForTests, GrokHookService } from './hook-service' +import { buildWindowsGrokHookScript } from './windows-grok-hook-script' import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ +type WindowsGrokHookRun = { + status: number | null + stderr: string + stdout: string + request?: { path: string; body: string } +} + +function createWindowsGrokHookEnvironment(grokHome?: string): NodeJS.ProcessEnv { + const env = { ...process.env } as NodeJS.ProcessEnv + delete env.ORCA_AGENT_HOOK_ENDPOINT + if (grokHome === undefined) { + delete env.GROK_HOME + } else { + env.GROK_HOME = grokHome + } + env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + env.ORCA_PANE_KEY = 'pane-test' + return env +} + +async function runWindowsGrokHook( + scriptPath: string, + env: NodeJS.ProcessEnv, + input: string +): Promise { + let request: WindowsGrokHookRun['request'] + const server = createServer((incoming, response) => { + let body = '' + incoming.setEncoding('utf8') + incoming.on('data', (chunk: string) => { + body += chunk + }) + incoming.on('end', () => { + request = { path: incoming.url ?? '', body } + response.writeHead(204).end() + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Could not resolve Windows Grok hook test listener port') + } + env.ORCA_AGENT_HOOK_PORT = String(address.port) + try { + const result = await new Promise>((resolve, reject) => { + const child = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', scriptPath], { + env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let stderr = '' + let stdout = '' + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk + }) + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + }) + child.once('error', reject) + child.once('close', (status) => resolve({ status, stderr, stdout })) + child.stdin.end(input) + }) + return { ...result, request } + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } +} + describe('GrokHookService', () => { let homeDir: string @@ -35,6 +111,108 @@ describe('GrokHookService', () => { rmSync(homeDir, { recursive: true, force: true }) }) + // Why: #9358 / #9941 — empty GROK_HOME + parse-time %VAR:~n,m% / `"\"` broke + // every SessionStart/UserPromptSubmit on Windows outside Orca terminals. + it('guards Windows GROK_HOME substring checks when empty (#9358)', () => { + const script = buildWindowsGrokHookScript() + expect(script).toContain('set "ORCA_GROK_HOME="') + expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('%GROK_HOME:~4096,1%') + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('%ORCA_GROK_HOME:~4096,1%') + expect(script).toContain(':orca_grok_home_ready') + expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"') + expect(script).toContain('if not "%GROK_HOME:~4096,1%"=="" goto :orca_grok_home_ready') + // Why: parenthesized `if defined (...)` still parse-expands the body early. + expect(script).not.toMatch(/if defined GROK_HOME \(/) + }) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 when GROK_HOME is unset (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-unset.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + // Why: delete GROK_HOME rather than set '' so cmd sees "not defined". + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(), + '{"hook_event_name":"SessionStart"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"SessionStart"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 with trailing-backslash GROK_HOME (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-slash.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const trailing = `${join(homeDir, 'grok-home-with-slash')}\\` + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(trailing), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe(`${trailing}.`) + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 with an oversized GROK_HOME (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-oversized.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(`C:\\${'a'.repeat(9000)}`), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'omits a max-length trailing-backslash GROK_HOME after safe normalization (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-max-trailing.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const trailingAtLimit = `C:\\${'a'.repeat(4092)}\\` + expect(trailingAtLimit).toHaveLength(4096) + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(trailingAtLimit), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + it('installs a dedicated global Grok hook config and managed script', () => { const status = new GrokHookService().install() @@ -89,11 +267,12 @@ describe('GrokHookService', () => { expect(script).toContain('/hook/grok') if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') - expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready') expect(script).toContain('%GROK_HOME:~4096,1%') - expect(script).toContain( - 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."' - ) + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('%ORCA_GROK_HOME:~4096,1%') + expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"') expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"') } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 564565d0d24..51a6150eab6 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -5,7 +5,6 @@ import { resolveGrokHomeDir } from '../../shared/grok-session-paths' import { buildManagedCommandHook, createManagedCommandMatcher, - buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, @@ -20,19 +19,17 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' import { - buildPosixHookPayloadCapture, - buildWindowsHookEnvironmentGuardLines, - buildWindowsHookStdinDrainEpilogue -} from '../agent-hooks/hook-stdin-contract' + buildWindowsGrokHookScript, + GROK_HOME_ENVELOPE_MAX_LENGTH +} from './windows-grok-hook-script' // Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare // `*` is not a valid "match all" pattern and can fail to load/match, so tool // lifecycle hooks never fire. `.*` matches every tool name (same as Command // Code's managed hooks). const GROK_TOOL_EVENT_MATCHER = '.*' -const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 -const WINDOWS_HOOK_PAYLOAD_FORM_LINE = ' --data-urlencode "payload@-" >nul 2>nul' const GROK_EVENTS = [ { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, @@ -95,11 +92,6 @@ function hasControlCharacter(value: string): boolean { }) } -const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok').replace( - WINDOWS_HOOK_PAYLOAD_FORM_LINE, - ` --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}` -) - function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' } @@ -116,21 +108,7 @@ function getManagedCommand(scriptPath: string): string { function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { - return [ - '@echo off', - 'setlocal', - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - ...buildWindowsHookEnvironmentGuardLines(), - 'set "ORCA_GROK_HOME=%GROK_HOME%"', - `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, - // Why: a trailing backslash escapes curl's closing argv quote on Windows, - // merging the payload option into grokHome and dropping the hook body. - 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', - WINDOWS_GROK_HOOK_POST_COMMAND, - 'exit /b 0', - ...buildWindowsHookStdinDrainEpilogue(), - '' - ].join('\r\n') + return buildWindowsGrokHookScript() } return [ diff --git a/src/main/grok/windows-grok-hook-script.ts b/src/main/grok/windows-grok-hook-script.ts new file mode 100644 index 00000000000..fb396dfafbc --- /dev/null +++ b/src/main/grok/windows-grok-hook-script.ts @@ -0,0 +1,45 @@ +import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' +import { + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' + +/** Matches the envelope length cap used by the POSIX grok-hook branch. */ +export const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 + +const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok', [ + // Why: attach grokHome before payload@- without string-replacing the shared template. + ' --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^' +]) + +/** + * Windows `grok-hook.cmd` body. + * + * Why (#9358 / #9941): cmd expands `%VAR:~n,m%` at parse time. When `GROK_HOME` + * is unset (the default outside an Orca-managed terminal), length/trailing + * guards become a syntax error and every Grok hook event fails with exit 255. + * + * - Guard substring ops behind `if defined` + goto (not a parenthesized block). + * - Reject oversized values before copying them onto a cmd input line. + */ +export function buildWindowsGrokHookScript(): string { + return [ + '@echo off', + 'setlocal', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + 'set "ORCA_GROK_HOME="', + 'if not defined GROK_HOME goto :orca_grok_home_ready', + `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" goto :orca_grok_home_ready`, + 'set "ORCA_GROK_HOME=%GROK_HOME%"', + 'if not defined ORCA_GROK_HOME goto :orca_grok_home_ready', + 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', + // Why: the trailing-backslash safety sentinel counts toward the relay envelope. + `if not "%ORCA_GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, + ':orca_grok_home_ready', + WINDOWS_GROK_HOOK_POST_COMMAND, + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') +} From 885afb55a9daaefe2aa566529f2522c237ff3c09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:32:58 -0700 Subject: [PATCH 05/46] fix(mobile): open host editor from root navigation (#12766) * fix(mobile): open host editor from root navigation * test(mobile): update task navigation router contract --- .../accounts/mobile-accounts-route.test.ts | 2 +- .../navigation/host-stack-navigation.test.ts | 50 +++++++++++++--- .../src/navigation/host-stack-navigation.ts | 14 +++++ .../notification-route-coordination.test.ts | 7 ++- .../src/session/mobile-session-route.test.ts | 2 +- .../src/tasks/mobile-task-navigation.test.ts | 60 +++++++++---------- .../transport/host-edit-navigation.test.ts | 12 ++-- 7 files changed, 102 insertions(+), 45 deletions(-) diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts index 03893215f2e..f938a98772c 100644 --- a/mobile/src/accounts/mobile-accounts-route.test.ts +++ b/mobile/src/accounts/mobile-accounts-route.test.ts @@ -45,7 +45,7 @@ describe('mobile accounts route', () => { navigateToHostStackRoute( harness.navigation, - { push }, + { push, replace: vi.fn() }, 'host/one', mobileAccountsRouteTarget('host/one') ) diff --git a/mobile/src/navigation/host-stack-navigation.test.ts b/mobile/src/navigation/host-stack-navigation.test.ts index dc3a94cfe7a..43848f35449 100644 --- a/mobile/src/navigation/host-stack-navigation.test.ts +++ b/mobile/src/navigation/host-stack-navigation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { coordinateHostStackNavigation, hostStackHostRoute, + hostStackRouteHref, navigateToHostStackRoute, type HostStackNavigationState } from './host-stack-navigation' @@ -69,7 +70,7 @@ describe('host stack navigation', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', TARGET) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -89,7 +90,12 @@ describe('host stack navigation', () => { rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] }) ) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(rootLayoutScopedState(committedHostState('host/one'))) expect(harness.navigation.dispatch).toHaveBeenCalledWith({ @@ -103,7 +109,12 @@ describe('host stack navigation', () => { it('survives the state emitted before the root navigator has hydrated', () => { const harness = navigationHarness(undefined) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) expect(() => harness.setState(undefined)).not.toThrow() expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -112,10 +123,30 @@ describe('host stack navigation', () => { expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1) }) + it('replaces through Expo Router when root state omits the mounted child stack', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() + + navigateToHostStackRoute(harness.navigation, { push: vi.fn(), replace }, 'host/one', TARGET) + harness.setState({ + index: 0, + routes: [{ name: 'h', params: { hostId: 'host/one' } }] + }) + + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).toHaveBeenCalledWith(hostStackRouteHref(TARGET)) + expect(harness.listenerCount()).toBe(0) + }) + it('abandons the transition when navigation leaves the host route it was waiting on', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState({ index: 0, routes: [{ name: 'h' }] }) harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState(committedHostState('host/one')) @@ -127,7 +158,12 @@ describe('host stack navigation', () => { it('ignores a different host whose id merely decodes badly', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(committedHostState('100%')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -138,7 +174,7 @@ describe('host stack navigation', () => { const controller = navigateToHostStackRoute( harness.navigation, - { push: vi.fn() }, + { push: vi.fn(), replace: vi.fn() }, 'host/one', TARGET ) @@ -156,7 +192,7 @@ describe('host stack navigation', () => { it('retargets a still-pending transition to the same host instead of pushing twice', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - const router = { push } + const router = { push, replace: vi.fn() } const pending = coordinateHostStackNavigation( null, diff --git a/mobile/src/navigation/host-stack-navigation.ts b/mobile/src/navigation/host-stack-navigation.ts index 6f73dd3e57a..cb3b87cd98f 100644 --- a/mobile/src/navigation/host-stack-navigation.ts +++ b/mobile/src/navigation/host-stack-navigation.ts @@ -35,8 +35,14 @@ export type HostStackRootNavigation = { export type HostStackHostRoute = `/h/${string}` +export type HostStackRouteHref = Readonly<{ + pathname: `/h/${string}` + params: Readonly> +}> + export type HostStackRouter = { push: (route: HostStackHostRoute) => void + replace: (route: HostStackRouteHref) => void } export type HostStackNavigationController = Readonly<{ @@ -54,6 +60,10 @@ export function hostStackHostRoute(hostId: string): HostStackHostRoute { return `/h/${encodeURIComponent(hostId)}` } +export function hostStackRouteHref(target: HostStackRouteTarget): HostStackRouteHref { + return { pathname: `/h/${target.name}`, params: target.params } +} + // Why: the host is pushed as an encoded segment, so the committed route may hold // either form — an id with `/`, `#`, or `%` must still match its own push. function hostParamMatches(param: unknown, expectedHostId: string): boolean { @@ -144,6 +154,10 @@ export function navigateToHostStackRoute( } const hostStack = hostContainer && mountedHostStack(hostContainer, hostId) if (!hostStack) { + if (hostContainer && hostParamMatches(hostContainer.params?.hostId, hostId)) { + dispose() + router.replace(hostStackRouteHref(selectedTarget)) + } return } dispose() diff --git a/mobile/src/notifications/notification-route-coordination.test.ts b/mobile/src/notifications/notification-route-coordination.test.ts index e41df45a8a2..eab13fdc046 100644 --- a/mobile/src/notifications/notification-route-coordination.test.ts +++ b/mobile/src/notifications/notification-route-coordination.test.ts @@ -47,7 +47,12 @@ describe('notification route coordination', () => { const harness = navigationHarness(undefined) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, target!.hostId, target!.sessionTarget!) + navigateToHostStackRoute( + harness.navigation, + { push, replace: vi.fn() }, + target!.hostId, + target!.sessionTarget! + ) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index 56b9fac7aa4..d9126475bc3 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -81,7 +81,7 @@ describe('mobile session route', () => { name: 'Fix #1' }) - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', target) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() diff --git a/mobile/src/tasks/mobile-task-navigation.test.ts b/mobile/src/tasks/mobile-task-navigation.test.ts index 5bc519578ba..bacc24be5b8 100644 --- a/mobile/src/tasks/mobile-task-navigation.test.ts +++ b/mobile/src/tasks/mobile-task-navigation.test.ts @@ -32,8 +32,9 @@ describe('mobile task navigation', () => { it('waits for the expected host commit before navigating to Tasks', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push }, 'host/1') + navigateToMobileTasks(harness.navigation, { push, replace }, 'host/1') expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan( push.mock.invocationCallOrder[0]! @@ -46,38 +47,25 @@ describe('mobile task navigation', () => { routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() - - harness.setState({ - index: 1, - routes: [ - { name: 'index' }, - { - name: 'h', - state: { - key: '/h', - index: 0, - routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host/1' } }] - } - } - ] - }) - expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan( - harness.navigation.dispatch.mock.invocationCallOrder[0]! + replace.mock.invocationCallOrder[0]! ) - expect(harness.navigation.dispatch).toHaveBeenCalledWith({ - type: 'REPLACE', - target: '/h', - source: 'host-index', - payload: { name: '[hostId]/tasks', params: { hostId: 'host/1' } } + expect(replace).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/tasks', + params: { hostId: 'host/1' } }) }) it('ignores unrelated state events and preserves the provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1', 'linear') + navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1', + 'linear' + ) harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) @@ -108,30 +96,37 @@ describe('mobile task navigation', () => { it('cleanup prevents a stale navigation from replacing', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') + const replace = vi.fn() + const controller = navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace }, + 'host-1' + ) controller.cancel() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('reuses a pending host push and applies the latest provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const router = { push, replace: vi.fn() } const first = coordinateMobileTasksNavigation( null, harness.navigation, - { push }, + router, 'host-1', 'github' ) const second = coordinateMobileTasksNavigation( first, harness.navigation, - { push }, + router, 'host-1', 'linear' ) @@ -161,11 +156,12 @@ describe('mobile task navigation', () => { ) }) - it('keeps waiting through host setup and cleans up when navigation leaves', () => { + it('keeps waiting through host setup without params and cleans up when navigation leaves', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) + navigateToMobileTasks(harness.navigation, { push: vi.fn(), replace }, 'host-1') + harness.setState({ index: 0, routes: [{ name: 'h' }] }) expect(harness.unsubscribeState).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState({ @@ -184,6 +180,7 @@ describe('mobile task navigation', () => { expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('unsubscribes when mounting the host throws synchronously', () => { @@ -196,7 +193,8 @@ describe('mobile task navigation', () => { { push: () => { throw error - } + }, + replace: vi.fn() }, 'host-1' ) diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts index cd0de461288..db463413a46 100644 --- a/mobile/src/transport/host-edit-navigation.test.ts +++ b/mobile/src/transport/host-edit-navigation.test.ts @@ -52,7 +52,7 @@ describe('mobile host edit navigation', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToMobileHostEdit(harness.navigation, { push }, 'host/1') + navigateToMobileHostEdit(harness.navigation, { push, replace: vi.fn() }, 'host/1') expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -75,7 +75,7 @@ describe('mobile host edit navigation', () => { it('does not replace an unrelated host route', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') harness.setState(committedHostState('host-2')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -83,7 +83,7 @@ describe('mobile host edit navigation', () => { it('disposes itself when navigation leaves the host flow without cancel()', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') // Enter the host flow for a different host, then leave it entirely. harness.setState(committedHostState('host-2')) @@ -97,7 +97,11 @@ describe('mobile host edit navigation', () => { it('cancels a pending replacement when navigation leaves the host flow', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + const controller = navigateToMobileHostEdit( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1' + ) harness.setState(committedHostState('host-2')) controller.cancel() From 7f4570c9a669d6b2c683ecd07e822fb95d32ff56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:36:08 -0700 Subject: [PATCH 06/46] fix(mobile): activate Source Control diff tabs on phones (#12770) * fix(mobile): activate source-control diff tabs on phones * fix(mobile): reveal legacy source control file tabs --- ...mobile-source-control-session-diff.test.ts | 233 ++++++++++++++++++ ...veal-mobile-source-control-session-diff.ts | 143 +++++++++++ .../use-mobile-source-control-openers.ts | 19 +- 3 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts create mode 100644 mobile/src/source-control/reveal-mobile-source-control-session-diff.ts diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts new file mode 100644 index 00000000000..bb76d2df75b --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' + +function success(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function clientWith(sendRequest: RpcClient['sendRequest']): Pick { + return { sendRequest } +} + +function options(sendRequest: RpcClient['sendRequest']) { + return { + client: clientWith(sendRequest), + worktreeId: 'worktree-1', + relativePath: 'src/target.ts', + tabMode: 'diff' as const, + staged: true + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('revealMobileSourceControlSessionDiff', () => { + it('activates the requested file when its diff tab is already open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + activeTabId: 'agent-tab', + tabs: [ + { id: 'agent-tab', type: 'terminal' }, + { + id: 'other-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/other.ts' + }, + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', { + worktree: 'id:worktree-1', + tabId: 'target-diff', + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + }) + + it('selects the staged diff when both versions of a file are open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + }, + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('waits for the requested diff source instead of activating the other one', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('activates a legacy edit tab when opening a diff falls back to files.open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'stale-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + }, + { + id: 'target-edit', + type: 'file', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-edit' })) + + await expect( + revealMobileSourceControlSessionDiff({ ...options(sendRequest), tabMode: 'edit' }) + ).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'target-edit' }) + ) + }) + + it('retries until the opened diff appears in the session snapshot', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce(success({ tabs: [{ id: 'agent-tab', type: 'terminal' }] })) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenCalledTimes(3) + }) + + it('delegates to the mounted session when the dock callback is available', async () => { + const sendRequest = vi.fn() + const onOpenedFileDiff = vi.fn() + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + onOpenedFileDiff + }) + ).resolves.toBe('revealed') + + expect(onOpenedFileDiff).toHaveBeenCalledWith('src/target.ts') + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels route-owned polling after the source-control screen unmounts', async () => { + let current = true + const sendRequest = vi.fn().mockImplementation(async () => { + current = false + return success({ tabs: [] }) + }) + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + isCurrent: () => current + }) + ).resolves.toBe('cancelled') + expect(sendRequest).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts new file mode 100644 index 00000000000..9391c0c2961 --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts @@ -0,0 +1,143 @@ +import type { RpcClient } from '../transport/rpc-client' +import { activateMobileSessionTab } from '../session/mobile-session-tab-activation' + +type ActivationClient = Pick + +type SessionFileTabCandidate = { + id: string + type: string + mode?: unknown + relativePath?: unknown + diffSource?: unknown +} + +type Options = { + client: ActivationClient + worktreeId: string + relativePath: string + tabMode: 'diff' | 'edit' + staged: boolean + onOpenedFileDiff?: (relativePath: string) => void + isCurrent?: () => boolean +} + +export type MobileSourceControlSessionDiffRevealResult = 'revealed' | 'cancelled' | 'timeout' + +const TAB_POLL_DELAYS_MS = [0, 300, 600, 900] as const + +export async function revealMobileSourceControlSessionDiff( + options: Options +): Promise { + if (options.onOpenedFileDiff) { + options.onOpenedFileDiff(options.relativePath) + return 'revealed' + } + + for (const delayMs of TAB_POLL_DELAYS_MS) { + await waitForDelay(delayMs) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + + const tab = await findOpenedSessionFileTab(options) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (!tab) { + continue + } + + const activated = await activateSessionFileTab(options, tab.id) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (activated) { + return 'revealed' + } + } + + return 'timeout' +} + +async function findOpenedSessionFileTab(options: Options): Promise { + try { + const response = await options.client.sendRequest('session.tabs.list', { + worktree: `id:${options.worktreeId}` + }) + if (!response.ok) { + return null + } + const snapshot = readTabSnapshot(response.result) + if (!snapshot) { + return null + } + + const matches = snapshot.tabs.filter( + (tab) => + tab.type !== 'browser' && + tab.type !== 'terminal' && + matchesTabMode(tab.mode, options.tabMode) && + tab.relativePath === options.relativePath + ) + if (options.tabMode === 'edit') { + return matches[0] ?? null + } + const source = options.staged ? 'staged' : 'unstaged' + return ( + matches.find((tab) => tab.diffSource === source) ?? + matches.find((tab) => tab.diffSource == null) ?? + null + ) + } catch { + return null + } +} + +function matchesTabMode(mode: unknown, expected: 'diff' | 'edit'): boolean { + return expected === 'diff' ? mode === 'diff' : mode === 'edit' || mode == null +} + +async function activateSessionFileTab(options: Options, tabId: string): Promise { + try { + const response = await activateMobileSessionTab(options.client, { + worktree: `id:${options.worktreeId}`, + tabId, + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + return response.ok && readActiveTabId(response.result) === tabId + } catch { + return false + } +} + +function readTabSnapshot(value: unknown): { tabs: SessionFileTabCandidate[] } | null { + if ( + !isRecord(value) || + !Array.isArray(value.tabs) || + !value.tabs.every(isSessionFileTabCandidate) + ) { + return null + } + return { tabs: value.tabs } +} + +function isSessionFileTabCandidate(value: unknown): value is SessionFileTabCandidate { + return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' +} + +function readActiveTabId(value: unknown): string | null { + return isRecord(value) && typeof value.activeTabId === 'string' ? value.activeTabId : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +async function waitForDelay(delayMs: number): Promise { + if (delayMs === 0) { + return + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} diff --git a/mobile/src/source-control/use-mobile-source-control-openers.ts b/mobile/src/source-control/use-mobile-source-control-openers.ts index bf0eff63cf6..f6c44825715 100644 --- a/mobile/src/source-control/use-mobile-source-control-openers.ts +++ b/mobile/src/source-control/use-mobile-source-control-openers.ts @@ -18,6 +18,7 @@ import { type MobileGitStatusEntry } from './mobile-git-status' import { buildMobileReviewFileRoute } from './mobile-review-route' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' import type { GitDiffTextResult, MobileBranchCompareState, @@ -115,11 +116,13 @@ export function useMobileSourceControlOpeners(params: Params) { relativePath: entry.path, staged: entry.area === 'staged' }) + let openedTabMode: 'diff' | 'edit' = 'diff' if (!response.ok && isMobileGitUnavailable(response.error?.code, response.error?.message)) { response = await client.sendRequest('files.open', { worktree: `id:${worktreeId}`, relativePath: entry.path }) + openedTabMode = 'edit' } if (!response.ok) { throw new Error(response.error?.message || 'Unable to open diff') @@ -127,8 +130,22 @@ export function useMobileSourceControlOpeners(params: Params) { if (!mountedRef.current) { return } + const revealResult = await revealMobileSourceControlSessionDiff({ + client, + worktreeId, + relativePath: entry.path, + tabMode: openedTabMode, + staged: entry.area === 'staged', + onOpenedFileDiff, + isCurrent: () => mountedRef.current && openingPathRef.current === entry.path + }) + if (revealResult === 'cancelled') { + return + } + if (revealResult === 'timeout') { + throw new Error("The file opened, but its tab isn't ready yet. Try again.") + } triggerSelection() - onOpenedFileDiff?.(entry.path) // Why: when launched from the session screen, opening a file dismisses // this surface back to the session. In embedded mode there is nothing // to pop (the panel docks beside the terminal), so close the dock From be2f9eddd39e668e19f9b0dd5fc3352125de8229 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:45:39 -0700 Subject: [PATCH 07/46] fix(mobile): abandon pairing journals that can no longer reconcile (#12773) --- .../mobile-relay-pairing-recovery.test.ts | 49 +++++++++++++++++++ .../mobile-relay-pairing-recovery.ts | 25 +++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts index 41d53189880..96d71b37680 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -220,4 +220,53 @@ describe('mobile relay pairing recovery', () => { expect(saved.metadata.authorizationMode).toBe('authenticated-direct') expect(deps.updateJournal).toHaveBeenCalledTimes(2) }) + // Why: a journal stranded by a relay outage used to block every later pairing + // with "recovery pending" forever, because recovery only ever deferred. + it('abandons a journal once its invite expired and no credential can reconcile', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned') + expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId) + }) + + it('keeps a just-expired journal so a brief outage cannot discard it', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + + // Why: a committed install whose local persistence failed is the one case the + // journal must survive — it is the only record left to retry the write from. + it('keeps a journal when the server committed but the local write failed', async () => { + const saved = journal() + const directInstalled = installed(saved, 'authenticated-direct') + const committed = client(async () => + response(endpoints(saved, { state: 'committed', result: directInstalled })) + ) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => committed) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1, + writeCredentialBundle: vi.fn(async () => { + throw new Error('keychain unavailable') + }) + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index 0d5ba7e5e07..26dc08620de 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -27,7 +27,7 @@ import { import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' import type { HostProfile, RpcResponse } from './types' -export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' +export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned' type RecoveryDependencies = { loadJournal: typeof loadMobileRelayPairingJournal @@ -57,6 +57,10 @@ const defaultDependencies: RecoveryDependencies = { platform: Platform.OS } +// One full invite lifetime past expiry, so a momentary outage never discards a +// journal that a later launch could still reconcile. +const ABANDON_GRACE_MS = 10 * 60 * 1000 + let recoveryPromise: Promise | null = null export function recoverMobileRelayPairing( @@ -96,6 +100,10 @@ async function runRecovery( } const credentials = recoveryCredentials(journal, bundle, dependencies.now()) + // Why: publishCommitted runs inside the catch below, so a failed local write + // of an authoritatively committed install must not look like "nothing to + // reconcile" — that journal is the only record left to retry the write from. + let observedCommitted = false for (const credential of credentials) { let client: PairingCandidateClient | null = null try { @@ -113,6 +121,7 @@ async function runRecovery( }) const endpoints = await getRecoveryStatus(client, journal, credential.kind) if (endpoints.installStatus?.state === 'committed') { + observedCommitted = true await publishCommitted(journal, endpoints, dependencies) return 'recovered' } @@ -128,6 +137,7 @@ async function runRecovery( ) const reconciled = await getRecoveryStatus(client, journal, 'invite') assertCommitted(reconciled, installed) + observedCommitted = true await publishCommitted(journal, reconciled, dependencies) return 'recovered' } @@ -138,6 +148,19 @@ async function runRecovery( client?.close() } } + // Why: past invite expiry no credential can still establish what happened, so + // retaining the journal cannot reconcile anything — it only fails every later + // pairing with "recovery pending" forever. Re-pairing mints a fresh device and + // any uncommitted server-side install expires on its own. The extra invite + // lifetime of slack keeps a brief relay outage from discarding a journal whose + // resume credential would have reconciled it on the next launch. + if ( + !observedCommitted && + journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now() + ) { + await dependencies.clearJournal(journal.metadata.journalId).catch(() => {}) + return 'abandoned' + } return 'deferred' } From 73cd4c3f46576b191a7625373bdc628120650dcf Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:52:14 -0700 Subject: [PATCH 08/46] fix(mobile): bound live terminal input latency (#12763) --- .../app/h/[hostId]/session/[worktreeId].tsx | 1 + .../session/MobileTerminalLiveInputStatus.tsx | 8 +- .../terminal-live-input-affordance.test.ts | 3 + .../terminal-live-pending-flush-state.test.ts | 77 ++++++++++-- .../terminal-live-pending-flush-state.ts | 111 +++++++++++++++--- .../use-terminal-live-pending-input-flush.ts | 24 +++- 6 files changed, 184 insertions(+), 40 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c5e4175acc0..755d6605501 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4986,6 +4986,7 @@ export default function SessionScreen() { {title} - + {detail} diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 0125cb053e6..ce360091ce2 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -39,6 +39,7 @@ describe('terminal live input affordance', () => { expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') + expect(block).toContain('liveInputText={liveInputCapture}') expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') expect(sessionRouteSource).toContain('return resetLiveInputFocus') expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') @@ -52,6 +53,8 @@ describe('terminal live input affordance', () => { it('makes the live keyboard target visible instead of status-only chrome', () => { expect(liveInputStatusSource).toContain("'Tap to show keyboard'") + expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'") + expect(liveInputStatusSource).toContain('ellipsizeMode="head"') expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised') expect(commandInputStylesSource).toContain('borderWidth: 1') expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed') diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts index 88918c00681..a77d4315c29 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -1,15 +1,16 @@ import { describe, expect, it } from 'vitest' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, - waitForTerminalLivePendingFlush, - type TerminalLivePendingFlushState + waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' describe('terminal live pending flush state', () => { it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When / Then await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) @@ -22,7 +23,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -48,7 +50,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -67,17 +70,45 @@ describe('terminal live pending flush state', () => { }) describe('terminal live mirror send queue', () => { + it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => { + // Given + const state = createTerminalLivePendingFlushState() + const payloads: string[] = [] + let resolveFirstSend: (value: boolean) => void = () => {} + const sender = async (_handle: string, payload: string): Promise => { + payloads.push(payload) + if (payloads.length === 1) { + return new Promise((resolve) => { + resolveFirstSend = resolve + }) + } + return true + } + + // When + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender) + await Promise.resolve() + + // Then + expect(payloads).toEqual(['a']) + resolveFirstSend(true) + await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true]) + expect(payloads).toEqual(['a', 'bc']) + }) + it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() const order: string[] = [] - const first = queueTerminalLiveMirrorSend(state, async () => { + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { order.push('first') return false }) // When - const second = queueTerminalLiveMirrorSend(state, async () => { + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => { order.push('second') return true }) @@ -90,13 +121,13 @@ describe('terminal live mirror send queue', () => { it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } - const first = queueTerminalLiveMirrorSend(state, async () => { + const state = createTerminalLivePendingFlushState() + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { throw new Error('boom') }) // When - const second = queueTerminalLiveMirrorSend(state, async () => true) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true) // Then await expect(first).resolves.toBe(false) @@ -105,13 +136,33 @@ describe('terminal live mirror send queue', () => { it('Given a settled mirror send When it was the newest Then the state resets to null', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When - await queueTerminalLiveMirrorSend(state, async () => true) + await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true) await Promise.resolve() // Then expect(state.current).toBeNull() }) + + it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => { + // Given + const state = createTerminalLivePendingFlushState() + let resolveSend: (value: boolean) => void = () => {} + const sender = async (): Promise => + new Promise((resolve) => { + resolveSend = resolve + }) + const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + + // When + cancelTerminalLivePendingFlush(state) + + // Then + await expect(Promise.all([active, pending])).resolves.toEqual([false, false]) + expect(state.current).toBeNull() + resolveSend(true) + }) }) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts index 405198964f3..fe0b4927f5a 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -1,5 +1,30 @@ +type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise + +type TerminalLivePendingRequest = { + readonly resolve: (sent: boolean) => void +} + +type TerminalLivePendingBatch = { + readonly handle: string + payload: string + readonly requests: TerminalLivePendingRequest[] + readonly sender: TerminalLiveMirrorSender +} + export type TerminalLivePendingFlushState = { current: Promise | null + activeRequests: TerminalLivePendingRequest[] + generation: number + pendingBatches: TerminalLivePendingBatch[] +} + +export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState { + return { + current: null, + activeRequests: [], + generation: 0, + pendingBatches: [] + } } export function waitForTerminalLivePendingFlush( @@ -8,26 +33,76 @@ export function waitForTerminalLivePendingFlush( return state.current ?? Promise.resolve(true) } -// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped -// delta desyncs every later diff, so this chain runs each send even when the -// previous one failed. state.current should never reject; the catch keeps a -// future raw assignment from skipping a delta. +export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void { + state.generation += 1 + const requests = [ + ...state.activeRequests, + ...state.pendingBatches.flatMap((batch) => batch.requests) + ] + state.activeRequests = [] + state.pendingBatches = [] + state.current = null + requests.forEach(({ resolve }) => resolve(false)) +} + +async function drainTerminalLiveMirrorSends( + state: TerminalLivePendingFlushState, + generation: number +): Promise { + let allSent = true + while (state.generation === generation) { + const batch = state.pendingBatches.shift() + if (!batch) { + state.current = null + return allSent + } + + state.activeRequests = batch.requests + const sent = await batch.sender(batch.handle, batch.payload).catch(() => false) + if (state.generation !== generation) { + return false + } + + state.activeRequests = [] + batch.requests.forEach(({ resolve }) => resolve(sent)) + allSent &&= sent + } + return false +} + +// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke. export function queueTerminalLiveMirrorSend( state: TerminalLivePendingFlushState, - sendMirrorPayload: () => Promise + handle: string, + payload: string, + sender: TerminalLiveMirrorSender ): Promise { - const previousSend = state.current - const sendPromise = (async () => { - if (previousSend) { - await previousSend.catch(() => false) - } - return sendMirrorPayload() - })().catch(() => false) - state.current = sendPromise - void sendPromise.then(() => { - if (state.current === sendPromise) { - state.current = null - } + let resolveRequest: (sent: boolean) => void = () => {} + const request = new Promise((resolve) => { + resolveRequest = resolve }) - return sendPromise + const pendingTail = state.pendingBatches.at(-1) + if (pendingTail?.handle === handle && pendingTail.sender === sender) { + pendingTail.payload += payload + pendingTail.requests.push({ resolve: resolveRequest }) + } else { + state.pendingBatches.push({ + handle, + payload, + requests: [{ resolve: resolveRequest }], + sender + }) + } + + if (!state.current) { + const generation = state.generation + const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false) + state.current = drain + void drain.then(() => { + if (state.current === drain) { + state.current = null + } + }) + } + return request } diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index f6239312947..05617442142 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -7,6 +7,8 @@ import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' @@ -39,7 +41,7 @@ export function useTerminalLivePendingInputFlush({ setLiveInputCapture }: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { const heldCommitTimerRef = useRef | null>(null) - const pendingLiveInputFlushRef = useRef | null>(null) + const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState()) const heldLiveInputTextRef = useRef('') const sentLiveInputTextRef = useRef('') const pendingLiveInputHandleRef = useRef(null) @@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush({ const resetMirrorState = useCallback(() => { clearHeldCommitTimer() + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null @@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush({ }, [liveInputRef, resetMirrorState, setLiveInputCapture]) const waitForPendingLiveInputFlush = useCallback(async (): Promise => { - return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) + return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current) }, []) + const sendQueuedMirrorPayload = useCallback( + (handle: string, payload: string): Promise => + sendLiveTerminalInputRef.current(handle, payload), + [sendLiveTerminalInputRef] + ) + const runMirrorStep = useCallback( async (handle: string, fieldText: string, commitHeld: boolean): Promise => { if ( @@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush({ if (payload.length === 0) { return waitForPendingLiveInputFlush() } - return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () => - sendLiveTerminalInputRef.current(handle, payload) + return queueTerminalLiveMirrorSend( + pendingLiveInputFlushRef.current, + handle, + payload, + sendQueuedMirrorPayload ) }, [ @@ -117,7 +129,7 @@ export function useTerminalLivePendingInputFlush({ clearHeldCommitTimer, liveInputTerminalHandlesRef, resetMirrorState, - sendLiveTerminalInputRef, + sendQueuedMirrorPayload, waitForPendingLiveInputFlush ] ) @@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush({ heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null - pendingLiveInputFlushRef.current = null + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) } }, []) From debf4affe7ed2dcbb4ad9fdad94a87bd92745831 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:46:00 -0700 Subject: [PATCH 09/46] Display total lines of code change in branch header (#12771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add branch line total chip to source control header Display the total lines added and removed across a branch from its fork point, measured via `git diff `. Only computed when the chip is visible (request gate on merge base OID), with 500ms soft deadline to protect status latency and 15s hard timeout. Deduplicated across concurrent pollers and cached alongside line stats. Omitted on failure — always shows exact or nothing, never a partial estimate. Updates throughout the stack: native git status, relay, renderer store/API, and UI components. * Pin branch line total to app locale Format line counts using the app's configured locale instead of the system locale, ensuring consistent cross-platform display and test reliability. * test: wait for coalescer joins instead of fixed sleep Hold the diff until the second status pass actually takes the branch-total coalescer lease instead of using a fixed 400ms sleep. Fixes timing-dependent flakiness on slow machines. --- src/main/git/git-status-read-lease-owner.ts | 104 +--- ...us-branch-line-total-exec-contract.test.ts | 330 ++++++++++++ .../status-branch-line-total-real-git.test.ts | 203 ++++++++ ...tus-branch-line-total-relay-parity.test.ts | 172 +++++++ src/main/git/status.ts | 68 ++- src/main/ipc/filesystem.ts | 4 + .../providers/git-provider-status-options.ts | 2 + src/main/providers/ssh-git-provider.ts | 7 +- src/main/runtime/rpc/methods/git-params.ts | 4 +- src/main/runtime/rpc/methods/git.ts | 4 + src/preload/api-types.ts | 2 + src/preload/index.ts | 1 + src/relay/git-handler-status-ops.ts | 33 +- src/relay/git-handler.ts | 2 + .../git-status-branch-line-total.test.ts | 475 ++++++++++++++++++ src/relay/git-status-branch-line-total.ts | 44 ++ .../SourceControl.branch-line-total.test.tsx | 340 +++++++++++++ ...SourceControl.open-file-highlight.test.tsx | 1 + .../SourceControl.preview-open.test.tsx | 1 + .../right-sidebar/SourceControl.tsx | 27 + .../SourceControl.virtual-file-list.test.tsx | 1 + .../branch-line-total-request-gate.test.ts | 52 ++ .../branch-line-total-request-gate.ts | 23 + ...t-status-refresh-branch-line-total.test.ts | 224 +++++++++ .../right-sidebar/git-status-refresh.ts | 23 +- ...source-control-branch-context-row.test.tsx | 142 ++++++ .../source-control-branch-context-row.tsx | 31 +- .../source-control-branch-context-stats.ts | 12 +- .../source-control-branch-line-total-chip.tsx | 74 +++ ...e-control-header-toolbar-identity.test.tsx | 16 + .../source-control-header-toolbar.tsx | 6 +- src/renderer/src/i18n/locales/en.json | 11 + ...ntime-git-client-branch-line-total.test.ts | 133 +++++ .../src/runtime/runtime-git-client.ts | 10 +- .../slices/editor-branch-line-total.test.ts | 225 +++++++++ src/renderer/src/store/slices/editor.ts | 28 ++ .../src/store/slices/worktrees.test.ts | 21 + src/renderer/src/store/slices/worktrees.ts | 5 + src/renderer/src/web/web-preload-api.test.ts | 93 ++++ src/renderer/src/web/web-preload-api.ts | 4 +- ...it-branch-line-total-soft-deadline.test.ts | 62 +++ src/shared/git-branch-line-total.test.ts | 434 ++++++++++++++++ src/shared/git-branch-line-total.ts | 214 ++++++++ ...git-status-branch-line-total-cache.test.ts | 236 +++++++++ src/shared/git-status-line-stats-cache.ts | 220 +++++--- .../git-status-line-stats-write-token.ts | 69 +++ src/shared/git-status-read-lease-owner.ts | 101 ++++ src/shared/git-status-types.ts | 12 + 48 files changed, 4112 insertions(+), 194 deletions(-) create mode 100644 src/main/git/status-branch-line-total-exec-contract.test.ts create mode 100644 src/main/git/status-branch-line-total-real-git.test.ts create mode 100644 src/main/git/status-branch-line-total-relay-parity.test.ts create mode 100644 src/relay/git-status-branch-line-total.test.ts create mode 100644 src/relay/git-status-branch-line-total.ts create mode 100644 src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts create mode 100644 src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts create mode 100644 src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control-branch-line-total-chip.tsx create mode 100644 src/renderer/src/runtime/runtime-git-client-branch-line-total.test.ts create mode 100644 src/renderer/src/store/slices/editor-branch-line-total.test.ts create mode 100644 src/shared/git-branch-line-total-soft-deadline.test.ts create mode 100644 src/shared/git-branch-line-total.test.ts create mode 100644 src/shared/git-branch-line-total.ts create mode 100644 src/shared/git-status-branch-line-total-cache.test.ts create mode 100644 src/shared/git-status-line-stats-write-token.ts create mode 100644 src/shared/git-status-read-lease-owner.ts diff --git a/src/main/git/git-status-read-lease-owner.ts b/src/main/git/git-status-read-lease-owner.ts index a0012da7d3a..eeae5eedf95 100644 --- a/src/main/git/git-status-read-lease-owner.ts +++ b/src/main/git/git-status-read-lease-owner.ts @@ -1,101 +1,3 @@ -type StatusReadEntry = { - controller: AbortController - promise: Promise - liveLeases: number - settled: boolean -} - -function getAbortReason(signal: AbortSignal): unknown { - try { - signal.throwIfAborted() - } catch (error) { - return error - } - return new DOMException('This operation was aborted', 'AbortError') -} - -export class GitStatusReadLeaseOwner { - private readonly entries = new Map>() - - lease( - key: string, - signal: AbortSignal | undefined, - load: (sharedSignal: AbortSignal) => Promise - ): Promise { - if (signal?.aborted) { - return Promise.reject(getAbortReason(signal)) - } - - let entry = this.entries.get(key) - if (!entry) { - const controller = new AbortController() - const promise = load(controller.signal) - const createdEntry = { controller, promise, liveLeases: 0, settled: false } - entry = createdEntry - this.entries.set(key, createdEntry) - void promise.then( - () => this.settle(key, createdEntry), - () => this.settle(key, createdEntry) - ) - } - - entry.liveLeases += 1 - return this.createLease(key, entry, signal) - } - - invalidate(): void { - this.entries.clear() - } - - private createLease( - key: string, - entry: StatusReadEntry, - signal: AbortSignal | undefined - ): Promise { - return new Promise((resolve, reject) => { - let active = true - const release = (abortReason?: unknown): boolean => { - if (!active) { - return false - } - active = false - signal?.removeEventListener('abort', onAbort) - entry.liveLeases -= 1 - if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) { - if (this.entries.get(key) === entry) { - this.entries.delete(key) - } - entry.controller.abort(abortReason) - } - return true - } - const onAbort = (): void => { - const reason = getAbortReason(signal!) - if (release(reason)) { - reject(reason) - } - } - - signal?.addEventListener('abort', onAbort, { once: true }) - void entry.promise.then( - (value) => { - if (release()) { - resolve(value) - } - }, - (error: unknown) => { - if (release()) { - reject(error) - } - } - ) - }) - } - - private settle(key: string, entry: StatusReadEntry): void { - entry.settled = true - if (this.entries.get(key) === entry) { - this.entries.delete(key) - } - } -} +// Why: the class moved to src/shared so the relay host can coalesce the same +// reads; this re-export keeps main's import path (and its tests) stable. +export { GitStatusReadLeaseOwner } from '../../shared/git-status-read-lease-owner' diff --git a/src/main/git/status-branch-line-total-exec-contract.test.ts b/src/main/git/status-branch-line-total-exec-contract.test.ts new file mode 100644 index 00000000000..13ece36daec --- /dev/null +++ b/src/main/git/status-branch-line-total-exec-contract.test.ts @@ -0,0 +1,330 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + buildGitBranchLineTotalDiffArgs, + invalidateGitBranchLineTotalInFlight +} from '../../shared/git-branch-line-total' + +import type * as BranchLineTotal from '../../shared/git-branch-line-total' +import type * as GitRunner from './runner' + +// Why: real git, spied argv. Every exec is recorded so the performance contract +// ("no ranged diff unless asked", "one exec for concurrent callers") can be +// asserted on the actual command list rather than on a stubbed return value. +const { gitExecCalls, execHooks, coalescerJoins } = vi.hoisted(() => ({ + gitExecCalls: [] as string[][], + execHooks: { beforeExec: undefined as undefined | ((args: string[]) => Promise | void) }, + coalescerJoins: { count: 0, onJoin: undefined as undefined | (() => void) } +})) + +vi.mock('../../shared/git-branch-line-total', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + computeGitBranchLineTotal: ( + input: Parameters[0] + ) => { + // The lease is taken synchronously inside, so counting after the call + // means a join is observable the instant it has happened. + const total = actual.computeGitBranchLineTotal(input) + coalescerJoins.count += 1 + coalescerJoins.onJoin?.() + return total + } + } +}) + +vi.mock('./runner', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + gitExecFileAsync: async (...args: Parameters) => { + gitExecCalls.push(args[0]) + await execHooks.beforeExec?.(args[0]) + return actual.gitExecFileAsync(...args) + }, + gitExecFileAsyncBuffer: async ( + ...args: Parameters + ) => { + gitExecCalls.push(args[0]) + return actual.gitExecFileAsyncBuffer(...args) + }, + gitStreamStdout: async (...args: Parameters) => { + gitExecCalls.push(args[0]) + return actual.gitStreamStdout(...args) + } + } +}) + +import { + clearEffectiveUpstreamStatusCacheForTests, + clearSubmodulePathsCacheForTests, + getStatus, + invalidateGitReadCaches +} from './status' + +const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5) +const tempRoots: string[] = [] + +function git(repo: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +async function createFixtureRepo(): Promise<{ repo: string; mergeBase: string }> { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-exec-')) + tempRoots.push(root) + const repo = path.join(root, 'repo') + execFileSync('git', ['init', '-q', repo]) + git(repo, ['config', 'user.email', 'test@example.com']) + git(repo, ['config', 'user.name', 'Test User']) + git(repo, ['config', 'commit.gpgSign', 'false']) + await write(repo, 'f.txt', 'a\nb\n') + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', 'base']) + await write(repo, 'f.txt', 'a\nb\nc\n') + return { repo, mergeBase: git(repo, ['rev-parse', 'HEAD']) } +} + +async function write(repo: string, relativePath: string, contents: string): Promise { + const target = path.join(repo, relativePath) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, contents) +} + +/** The `mergeBase → worktree` diff, distinguished from the per-area numstats by its rev + `--`. */ +function rangedDiffCalls(): string[][] { + return gitExecCalls.filter((args) => args.includes('--numstat') && args.at(-1) === '--') +} + +function numstatCalls(): string[][] { + return gitExecCalls.filter((args) => args.includes('--numstat')) +} + +/** Resolves once `count` status passes have entered the branch-total coalescer. */ +function waitForCoalescerJoins(count: number): Promise { + if (coalescerJoins.count >= count) { + return Promise.resolve() + } + return new Promise((resolve) => { + coalescerJoins.onJoin = () => { + if (coalescerJoins.count >= count) { + coalescerJoins.onJoin = undefined + resolve() + } + } + }) +} + +function resetGitReadCaches(): void { + clearEffectiveUpstreamStatusCacheForTests() + clearSubmodulePathsCacheForTests() + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() +} + +beforeEach(() => { + gitExecCalls.length = 0 + execHooks.beforeExec = undefined + coalescerJoins.count = 0 + coalescerJoins.onJoin = undefined + resetGitReadCaches() +}) + +afterEach(async () => { + execHooks.beforeExec = undefined + coalescerJoins.onJoin = undefined + resetGitReadCaches() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('branch line total completeness', () => { + it('omits the total when the status listing hit its limit', async () => { + const { repo, mergeBase } = await createFixtureRepo() + await write(repo, 'u1.txt', 'a\n') + await write(repo, 'u2.txt', 'b\n') + await write(repo, 'u3.txt', 'c\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 1 }) + + expect(result.didHitLimit).toBe(true) + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it('omits the total when the ranged numstat fails, rather than reporting zero', async () => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo, { branchLineTotalMergeBase: BOGUS_MERGE_BASE }) + + expect(result.entries.length).toBeGreaterThan(0) + expect(result.branchLineTotal).toBeUndefined() + // The diff really was attempted, so this is a failure path and not a gate. + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('omits the total for a directory that is not a git worktree', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-folder-')) + tempRoots.push(root) + + const result = await getStatus(root, { branchLineTotalMergeBase: BOGUS_MERGE_BASE }) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it('rejects with an AbortError when the pass is cancelled mid-diff', async () => { + const { repo, mergeBase } = await createFixtureRepo() + const controller = new AbortController() + execHooks.beforeExec = (args) => { + if (args.includes('--numstat') && args.at(-1) === '--') { + controller.abort() + } + } + + await expect( + getStatus(repo, { branchLineTotalMergeBase: mergeBase, signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + // The pass had already streamed status and reached the ranged diff, so this + // is a cancellation in flight rather than a pre-flight rejection. + expect(rangedDiffCalls()).toHaveLength(1) + }) +}) + +describe('branch line total merge-base gate', () => { + it('runs no ranged diff and returns no total without a merge base', async () => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + }) + + it.each(['--upload-pack=x', 'HEAD', '', 'origin/main', '-M', 'A1B2C3D'])( + 'never spawns a ranged diff for a merge base of %j', + async (branchLineTotalMergeBase) => { + const { repo } = await createFixtureRepo() + + const result = await getStatus(repo, { branchLineTotalMergeBase }) + + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls()).toEqual([]) + expect(gitExecCalls).not.toContainEqual( + buildGitBranchLineTotalDiffArgs(branchLineTotalMergeBase) + ) + } + ) + + it('never leaks a flag-shaped merge base into any git argv', async () => { + const { repo } = await createFixtureRepo() + + await getStatus(repo, { branchLineTotalMergeBase: '--upload-pack=/tmp/evil' }) + + for (const args of gitExecCalls) { + expect(args).not.toContain('--upload-pack=/tmp/evil') + } + }) +}) + +describe('branch line total exec budget', () => { + it('costs exactly one extra exec versus the same pass with the chip hidden', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + await getStatus(repo) + const withoutChip = gitExecCalls.map((args) => args.join(' ')) + + gitExecCalls.length = 0 + resetGitReadCaches() + await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + const withChip = gitExecCalls.map((args) => args.join(' ')) + + expect(withChip.filter((args) => !args.endsWith(`${mergeBase} --`))).toEqual(withoutChip) + expect(withChip).toHaveLength(withoutChip.length + 1) + }) + + it('shares one ranged diff between two concurrent identical status passes', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + const [first, second] = await Promise.all([ + getStatus(repo, { branchLineTotalMergeBase: mergeBase }), + getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + ]) + + expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('shares one ranged diff across status passes the status lease cannot merge', async () => { + const { repo, mergeBase } = await createFixtureRepo() + // Why: `limit` is part of the status read key, so these two passes each run + // their own `git status`; only the branch-total coalescer can merge the diff. + // Hold the first diff exactly until the second pass has taken the lease: a + // fixed sleep would either release too early or, on a slow machine, outrun + // GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS and publish without an exact total. + execHooks.beforeExec = async (args) => { + if (args.includes('--numstat') && args.at(-1) === '--') { + await waitForCoalescerJoins(2) + } + } + + const [first, second] = await Promise.all([ + getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 0 }), + getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 4096 }) + ]) + + expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + // Proves the saving came from the branch-total coalescer: both passes really + // ran their own `git status`, so the status lease merged nothing. + expect(gitExecCalls.filter((args) => args.includes('status'))).toHaveLength(2) + expect(rangedDiffCalls()).toHaveLength(1) + }) + + it('reuses the cached total when the pass reuses cached line stats', async () => { + const { repo, mergeBase } = await createFixtureRepo() + + const first = await getStatus(repo, { + branchLineTotalMergeBase: mergeBase, + reuseLineStats: true + }) + expect(rangedDiffCalls()).toHaveLength(1) + expect(numstatCalls()).toHaveLength(2) + + gitExecCalls.length = 0 + invalidateGitBranchLineTotalInFlight() + const second = await getStatus(repo, { + branchLineTotalMergeBase: mergeBase, + reuseLineStats: true + }) + + expect(second.branchLineTotal).toEqual(first.branchLineTotal) + expect(second.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(numstatCalls()).toEqual([]) + }) + + it('recomputes rather than reusing a total measured against another merge base', async () => { + const { repo, mergeBase } = await createFixtureRepo() + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', 'second']) + const laterMergeBase = git(repo, ['rev-parse', 'HEAD']) + + await getStatus(repo, { branchLineTotalMergeBase: mergeBase, reuseLineStats: true }) + gitExecCalls.length = 0 + invalidateGitBranchLineTotalInFlight() + const result = await getStatus(repo, { + branchLineTotalMergeBase: laterMergeBase, + reuseLineStats: true + }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase: laterMergeBase }) + expect(rangedDiffCalls()).toHaveLength(1) + }) +}) diff --git a/src/main/git/status-branch-line-total-real-git.test.ts b/src/main/git/status-branch-line-total-real-git.test.ts new file mode 100644 index 00000000000..80426c76e65 --- /dev/null +++ b/src/main/git/status-branch-line-total-real-git.test.ts @@ -0,0 +1,203 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' +import { getStatus, invalidateGitReadCaches } from './status' + +const tempRoots: string[] = [] + +function git(repo: string, args: string[]): string { + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +async function createFixtureRepo(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-')) + tempRoots.push(root) + const repo = path.join(root, 'repo') + execFileSync('git', ['init', '-q', repo]) + git(repo, ['config', 'user.email', 'test@example.com']) + git(repo, ['config', 'user.name', 'Test User']) + git(repo, ['config', 'commit.gpgSign', 'false']) + return repo +} + +async function write(repo: string, relativePath: string, contents: string | Buffer): Promise { + const target = path.join(repo, relativePath) + await mkdir(path.dirname(target), { recursive: true }) + await writeFile(target, contents) +} + +/** The forked-from commit the chip measures against, as branch compare would resolve it. */ +function commitAll(repo: string, message: string): string { + git(repo, ['add', '-A']) + git(repo, ['commit', '-q', '-m', message]) + return git(repo, ['rev-parse', 'HEAD']) +} + +/** What a plain `git diff ` reports, i.e. the number the chip must not disagree with. */ +function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const line of git(repo, ['diff', '--numstat', '-M', mergeBase, '--']).split(/\r?\n/)) { + if (!line) { + continue + } + const [rawAdded, rawRemoved] = line.split('\t') + added += rawAdded === '-' ? 0 : Number.parseInt(rawAdded ?? '0', 10) + removed += rawRemoved === '-' ? 0 : Number.parseInt(rawRemoved ?? '0', 10) + } + return { added, removed } +} + +beforeEach(() => { + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() +}) + +afterEach(async () => { + invalidateGitReadCaches() + invalidateGitBranchLineTotalInFlight() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('branch line total against a real repository', () => { + it('measures a partially staged line once, not once per status area', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'line1\n') + const mergeBase = commitAll(repo, 'base') + // Stage "+foo", then edit that same line to "bar" without staging it: the + // per-area rows read +1/-0 staged and +1/-1 unstaged, summing to +2/-1. + await write(repo, 'f.txt', 'line1\nfoo\n') + git(repo, ['add', 'f.txt']) + await write(repo, 'f.txt', 'line1\nbar\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + expect(result.entries.map((entry) => [entry.area, entry.added, entry.removed])).toEqual([ + ['staged', 1, 0], + ['unstaged', 1, 1] + ]) + }) + + it('nets a line added in a branch commit and deleted in the working tree to zero', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\n') + commitAll(repo, 'add b') + await write(repo, 'f.txt', 'a\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('keeps the total across a commit and updates it on the next status call after an edit', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\nc\n') + + const beforeCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(beforeCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + + commitAll(repo, 'commit the edit') + const afterCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(afterCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase }) + + await write(repo, 'f.txt', 'a\nb\nc\nd\n') + const afterSecondEdit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + expect(afterSecondEdit.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + }) + + it('counts a rename across the commit boundary once, using the post-rename path', async () => { + const repo = await createFixtureRepo() + await write(repo, 'old.txt', 'a\nb\nc\nd\ne\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['mv', 'old.txt', 'new.txt']) + commitAll(repo, 'rename') + await write(repo, 'new.txt', 'a\nb\nc\nd\ne\nf\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase }) + }) + + it('counts an untracked-only branch from the untracked file contents', async () => { + const repo = await createFixtureRepo() + await write(repo, 'kept.txt', 'a\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'src/new.ts', 'one\ntwo\nthree\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.entries.map((entry) => entry.area)).toEqual(['untracked']) + expect(result.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase }) + }) + + it('excludes a binary-only change, matching numstat reporting it as "-"', async () => { + const repo = await createFixtureRepo() + await write(repo, 'blob.bin', Buffer.from([0, 1, 2, 3, 10, 65, 66])) + const mergeBase = commitAll(repo, 'base') + await write(repo, 'blob.bin', Buffer.from([0, 9, 10, 11, 12, 67, 68, 69, 70])) + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(git(repo, ['diff', '--numstat', mergeBase, '--'])).toMatch(/^-\t-\t/) + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('contributes nothing for a pure rename', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\nb\nc\nd\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['mv', 'f.txt', 'g.txt']) + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase }) + }) + + it('includes untracked additions alongside tracked range changes', async () => { + const repo = await createFixtureRepo() + await write(repo, 'f.txt', 'a\nb\n') + const mergeBase = commitAll(repo, 'base') + await write(repo, 'f.txt', 'a\nb\nc\n') + commitAll(repo, 'append c') + await write(repo, 'untracked.txt', 'u1\nu2\nu3\nu4\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 }) + expect(result.branchLineTotal).toEqual({ added: 5, removed: 0, mergeBase }) + }) + + // Documented deviation from a pure `git diff`: the untracked half is added on + // top of the tracked range, so a deleted-then-recreated path is counted twice. + // Locked in deliberately — changing it must be a deliberate spec change. + it('counts both the deletion and the recreated untracked file for the same path', async () => { + const repo = await createFixtureRepo() + await write(repo, 'gone.txt', 'a\nb\nc\n') + const mergeBase = commitAll(repo, 'base') + git(repo, ['rm', '-q', 'gone.txt']) + commitAll(repo, 'delete gone.txt') + await write(repo, 'gone.txt', 'n1\nn2\n') + + const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + + expect(result.entries.map((entry) => [entry.area, entry.path])).toEqual([ + ['untracked', 'gone.txt'] + ]) + expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 0, removed: 3 }) + expect(result.branchLineTotal).toEqual({ added: 2, removed: 3, mergeBase }) + }) +}) diff --git a/src/main/git/status-branch-line-total-relay-parity.test.ts b/src/main/git/status-branch-line-total-relay-parity.test.ts new file mode 100644 index 00000000000..e9f5240728c --- /dev/null +++ b/src/main/git/status-branch-line-total-relay-parity.test.ts @@ -0,0 +1,172 @@ +/** + * Main and relay must publish the same `branchLineTotal` for the same fixture + * repo. Both call sites + * share `src/shared/git-branch-line-total.ts`; this is the test that catches one + * of them wiring it up differently — different flags, a different untracked + * source, a different completeness gate. + */ +import { execFile, execFileSync } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { getStatus } from './status' +import type { GitExec } from '../../relay/git-handler-ops' +import { getStatusOp } from '../../relay/git-handler-status-ops' +import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' +import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total' +import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache' + +const execFileAsync = promisify(execFile) + +// Fork point → working tree for the fixture below: +// tracked.txt +2 (branch commit only) +// partial.txt +1 (staged +2 then one of those lines removed unstaged) +// flip.txt 0 (added in the branch commit, removed again in the worktree) +// moved.txt 0 (pure rename) +// fresh.txt +3 (untracked) +const EXPECTED_TOTAL = { added: 6, removed: 0 } +// Summing the per-area status rows instead would give this — the wrong answer +// the shared module exists to avoid. +const AREA_ROW_SUM = { added: 5, removed: 2 } + +const relayGit: GitExec = async (args, cwd, opts) => { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(opts?.timeout ? { timeout: opts.timeout } : {}) + }) + return { stdout, stderr } +} + +const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => { + const { stdout } = await relayGit(args, cwd, { + disableOptionalLocks: options.disableOptionalLocks, + signal: options.signal + }) + return { stoppedEarly: options.onStdout(stdout) === true } +} + +function runFixtureGit(repo: string, args: string[]): string { + return execFileSync( + 'git', + [ + '-c', + 'user.email=test@test.com', + '-c', + 'user.name=Test', + '-c', + 'commit.gpgSign=false', + ...args + ], + { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ).trim() +} + +/** Returns the merge-base OID the chip is measured against. */ +async function seedParityFixture(repo: string): Promise { + execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) + await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n') + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n') + await writeFile(path.join(repo, 'renamed.txt'), 'stable\n') + await writeFile(path.join(repo, 'flip.txt'), 'p\n') + runFixtureGit(repo, ['add', '.']) + runFixtureGit(repo, ['commit', '-m', 'base']) + const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) + + runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) + await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n') + await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n') + runFixtureGit(repo, ['add', '-A']) + runFixtureGit(repo, ['commit', '-m', 'branch commit']) + + runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt']) + // Staged and unstaged hunks land on the same added lines, so an area sum + // double-counts them. + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n') + runFixtureGit(repo, ['add', 'partial.txt']) + await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n') + // The branch commit's line, taken back out in the worktree: net zero. + await writeFile(path.join(repo, 'flip.txt'), 'p\n') + await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n') + return mergeBase +} + +function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): { + added: number + removed: number +} { + let added = 0 + let removed = 0 + for (const entry of entries) { + added += entry.added ?? 0 + removed += entry.removed ?? 0 + } + return { added, removed } +} + +describe('branch line total parity between main and relay', () => { + let repo: string + + beforeEach(async () => { + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-')) + }) + + afterEach(async () => { + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + await rm(repo, { recursive: true, force: true }) + }) + + it('produces identical totals for the same fixture repo', async () => { + const mergeBase = await seedParityFixture(repo) + + const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { + worktreePath: repo, + branchLineTotalMergeBase: mergeBase + }) + + expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase }) + expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) + // Both sides model the same worktree, so a parity pass on a mismatched + // entry list would be meaningless. + expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual( + mainStatus.entries.map((entry) => entry.path).sort() + ) + }) + + it('agrees on a number no per-area row sum could produce', async () => { + const mergeBase = await seedParityFixture(repo) + + const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase }) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { + worktreePath: repo, + branchLineTotalMergeBase: mergeBase + }) + + expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM) + expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual( + AREA_ROW_SUM + ) + expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM) + expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal) + }) + + it('omits the total on both sides when no merge base is requested', async () => { + await seedParityFixture(repo) + + const mainStatus = await getStatus(repo) + clearGitStatusLineStatsCache() + const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo }) + + expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false) + expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false) + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 120c84869d2..c03d9307112 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -54,6 +54,13 @@ import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner' import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output' +import { + computeGitBranchLineTotal, + invalidateGitBranchLineTotalInFlight, + readGitBranchLineTotalMergeBaseParam, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../../shared/git-branch-line-total' import { beginGitStatusLineStatsCacheWrite, clearGitStatusLineStatsCache, @@ -95,10 +102,12 @@ const gitDiffReadDedupe = new InFlightPromiseDedupe() const effectiveUpstreamStatusWriteGeneration = new Map() const statusReadLeaseOwner = new GitStatusReadLeaseOwner() -// Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read. +// Why: clear every in-flight git read cache; clearing only some would let a post-mutation +// getStatus() join a pre-mutation read and publish it as current. export function invalidateGitReadCaches(): void { gitDiffReadDedupe.clear() statusReadLeaseOwner.invalidate() + invalidateGitBranchLineTotalInFlight() clearGitStatusLineStatsCache() clearSubmodulePathsCache() resolvedUpstreamNameCache.clear() @@ -195,6 +204,9 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number { export type GetStatusOptions = GitRuntimeOptions & { includeIgnored?: boolean reuseLineStats?: boolean + /** Merge-base OID the caller wants the branch line total measured against; + * omitted means the chip is hidden, so no ranged diff runs at all. */ + branchLineTotalMergeBase?: string /** * Max changed-file entries before git is stopped and the result is marked * `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap. @@ -232,6 +244,9 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri options.wslDistro ?? '', options.includeIgnored === true, options.reuseLineStats === true, + // Why: the result carries a total only for callers who asked, and only for + // this fork point, so a shared lease must never serve one to the other. + options.branchLineTotalMergeBase ?? '', options.bypassEffectiveUpstreamNegativeCache === true, limit, // Why: this changes which entries survive, so it must not share a cache slot. @@ -371,16 +386,25 @@ async function runGetStatus( } // Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set. + let branchLineTotal: GitBranchLineTotal | undefined if (!didHitLimit) { - await reuseOrRecomputeGitStatusLineStats({ + const branchLineTotalInput = createBranchLineTotalInput( + worktreePath, + entries, + options, + statusSucceeded + ) + const lineStats = await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: options.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, - recompute: () => attachLineStats(worktreePath, entries, options) + recompute: () => attachLineStats(worktreePath, entries, options), + ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) }) + branchLineTotal = lineStats.branchLineTotal } else { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } @@ -398,6 +422,7 @@ async function runGetStatus( head, branch, ...(options.includeIgnored ? { ignoredPaths: parser.ignoredPaths } : {}), + ...(branchLineTotal ? { branchLineTotal } : {}), ...(didHitLimit ? { didHitLimit: true, statusLength: parser.statusLength } : {}), ...(statusSucceeded ? { @@ -416,6 +441,43 @@ async function runGetStatus( } } +/** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */ +function createBranchLineTotalInput( + worktreePath: string, + entries: GitStatusEntry[], + options: GetStatusOptions, + statusSucceeded: boolean +): { mergeBase: string; compute: () => Promise } | undefined { + const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase) + // Why: a failed status scan leaves the untracked list untrustworthy, so the + // total would silently under-count rather than be absent. + if (mergeBase === undefined || !statusSucceeded) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Why: the same path can be a different filesystem per WSL distro. + hostKey: options.wslDistro ?? 'native', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path), + runDiffNumstat: (args, signal) => + gitExecFileAsync(args, { + ...gitOptionsForWorktree(worktreePath, options), + // Why: after the spread, so the shared lease signal wins over this caller's own. + signal, + env: gitOptionalLocksDisabledEnv(), + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then((result) => result.stdout), + ...(options.signal ? { signal: options.signal } : {}) + }) + } +} + function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string { // Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host. return `${options.wslDistro ?? 'native'}\0${worktreePath}` diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index f3c91088d0a..bf4aff54f04 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1127,6 +1127,7 @@ export function registerFilesystemHandlers( includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string requestToken?: string } ): Promise => { @@ -1134,6 +1135,9 @@ export function registerFilesystemHandlers( const options = { includeIgnored: args.includeIgnored ?? false, ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(args.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), ...(args.bypassEffectiveUpstreamNegativeCache === true ? { bypassEffectiveUpstreamNegativeCache: true } : {}), diff --git a/src/main/providers/git-provider-status-options.ts b/src/main/providers/git-provider-status-options.ts index 3d03ca6a269..a8924924f22 100644 --- a/src/main/providers/git-provider-status-options.ts +++ b/src/main/providers/git-provider-status-options.ts @@ -3,5 +3,7 @@ export type GitProviderStatusOptions = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string signal?: AbortSignal } diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 2ec2fbbfc6f..e4c306529ee 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -129,11 +129,16 @@ export class SshGitProvider implements IGitProvider { ? { bypassEffectiveUpstreamNegativeCache: true } : {} const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {} + const branchLineTotalArgs = + options?.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: options.branchLineTotalMergeBase } const request = { worktreePath, ...includeIgnoredArgs, ...upstreamCacheBypassArgs, - ...lineStatsReuseArgs + ...lineStatsReuseArgs, + ...branchLineTotalArgs } return (await (options?.signal ? this.mux.request('git.status', request, { signal: options.signal }) diff --git a/src/main/runtime/rpc/methods/git-params.ts b/src/main/runtime/rpc/methods/git-params.ts index 7e13e584c42..0fe273108a5 100644 --- a/src/main/runtime/rpc/methods/git-params.ts +++ b/src/main/runtime/rpc/methods/git-params.ts @@ -10,7 +10,9 @@ export const WorktreeSelector = z.object({ export const GitStatusParams = WorktreeSelector.extend({ includeIgnored: z.boolean().optional(), bypassEffectiveUpstreamNegativeCache: z.boolean().optional(), - reuseLineStats: z.boolean().optional() + reuseLineStats: z.boolean().optional(), + // Shape is re-validated host-side before it reaches a git argv. + branchLineTotalMergeBase: z.string().optional() }) export const GitCheckIgnored = WorktreeSelector.extend({ diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index c4ccdf071b6..c30e80f4338 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -93,6 +93,7 @@ export const GIT_METHODS: RpcMethod[] = [ params.includeIgnored === undefined && params.bypassEffectiveUpstreamNegativeCache === undefined && params.reuseLineStats === undefined && + params.branchLineTotalMergeBase === undefined && signal === undefined ? undefined : { @@ -103,6 +104,9 @@ export const GIT_METHODS: RpcMethod[] = [ ? { bypassEffectiveUpstreamNegativeCache: true } : {}), ...(params.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(params.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: params.branchLineTotalMergeBase }), ...(signal ? { signal } : {}) } return options === undefined diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0dc78393f28..094a93c3484 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2898,6 +2898,8 @@ export type PreloadApi = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + /** Merge-base OID to measure the branch line total against; omit to skip the work. */ + branchLineTotalMergeBase?: string requestToken?: string }) => Promise cancelStatus: (args: { requestToken: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 36e26870868..8b3863dcf7c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3230,6 +3230,7 @@ const api = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean reuseLineStats?: boolean + branchLineTotalMergeBase?: string requestToken?: string }): Promise => ipcRenderer.invoke('git:status', args), cancelStatus: (args: { requestToken: string }): Promise => diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 880e690ee54..60ea5913139 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -24,6 +24,11 @@ import { clearGitStatusLineStatsCacheKey, reuseOrRecomputeGitStatusLineStats } from '../shared/git-status-line-stats-cache' +import { + readGitBranchLineTotalMergeBaseParam, + type GitBranchLineTotal +} from '../shared/git-branch-line-total' +import { buildBranchLineTotalInput } from './git-status-branch-line-total' export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') @@ -74,11 +79,16 @@ export async function getStatusOp( ignoredPaths?: string[] didHitLimit?: boolean statusLength?: number + branchLineTotal?: GitBranchLineTotal }> { const worktreePath = params.worktreePath as string const lineStatsCacheKey = `relay\0${worktreePath}` const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey) const includeIgnored = params.includeIgnored === true + // Why: untrusted RPC input spliced into a git argv — only an OID shape may pass. + const branchLineTotalMergeBase = readGitBranchLineTotalMergeBaseParam( + params.branchLineTotalMergeBase + ) // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) const conflictOperation = await detectConflictOperation(worktreePath) @@ -89,6 +99,8 @@ export async function getStatusOp( let ignoredPaths: string[] = [] let didHitLimit = false let statusLength = 0 + let statusSucceeded = false + let branchLineTotal: GitBranchLineTotal | undefined try { // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. @@ -118,6 +130,7 @@ export async function getStatusOp( ignoredPaths = parser.ignoredPaths statusLength = parser.statusLength didHitLimit = stoppedEarly + statusSucceeded = true const { upstreamName, upstreamAheadBehind } = parser.branch upstreamStatus = upstreamName ? { @@ -173,15 +186,26 @@ export async function getStatusOp( // Why: skip numstat after the limit to avoid reintroducing its cost. if (!didHitLimit) { - await reuseOrRecomputeGitStatusLineStats({ + const branchLineTotalInput = buildBranchLineTotalInput( + git, + worktreePath, + entries, + // Why: a failed scan leaves the untracked list untrustworthy, so the total + // would under-count — omit it rather than publish a confident wrong number. + statusSucceeded ? branchLineTotalMergeBase : undefined, + options.signal + ) + // Why: passed in so the ranged diff runs alongside the per-area numstats, not after them. + ;({ branchLineTotal } = await reuseOrRecomputeGitStatusLineStats({ cacheKey: lineStatsCacheKey, head, entries, writeToken: lineStatsWriteToken, reuse: params.reuseLineStats === true, isAborted: () => options.signal?.aborted === true, - recompute: () => attachLineStats(git, worktreePath, entries, options.signal) - }) + recompute: () => attachLineStats(git, worktreePath, entries, options.signal), + ...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {}) + })) } else { clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken) } @@ -200,7 +224,8 @@ export async function getStatusOp( branch, upstreamStatus, ...(includeIgnored ? { ignoredPaths } : {}), - ...(didHitLimit ? { didHitLimit: true, statusLength } : {}) + ...(didHitLimit ? { didHitLimit: true, statusLength } : {}), + ...(branchLineTotal ? { branchLineTotal } : {}) } } diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 6674500c6f9..0abc9f5d5be 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -89,6 +89,7 @@ import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' import { endSubprocessStdin } from '../shared/subprocess-stdin-write' import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' +import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' import { streamRelayGitStdout } from './git-stdout-stream' const execFileAsync = promisify(execFile) @@ -298,6 +299,7 @@ export class GitHandler { private clearGitMutationReadCaches(): void { this.gitDiffReadDedupe.clear() + invalidateGitBranchLineTotalInFlight() clearGitStatusLineStatsCache() clearSubmodulePathsCache(this.submodulePathsCache) } diff --git a/src/relay/git-status-branch-line-total.test.ts b/src/relay/git-status-branch-line-total.test.ts new file mode 100644 index 00000000000..ceb04351e07 --- /dev/null +++ b/src/relay/git-status-branch-line-total.test.ts @@ -0,0 +1,475 @@ +/** + * Relay-side contract for the status pass's branch line total: the chip is an + * exact number or absent, and costs nothing when nobody asked for it. + */ +import { execFile, execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import * as path from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total' +import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache' +import type { GitExec } from './git-handler-ops' +import { getStatusOp } from './git-handler-status-ops' +import type { RelayGitStreamExec } from './git-stdout-stream' +import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache' + +type GitCall = Parameters + +const execFileAsync = promisify(execFile) +const MERGE_BASE = '0123456789abcdef0123456789abcdef01234567' +const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98' +// Untracked additions ride on top of the ranged diff, so the fixture needs a real file. +const UNTRACKED_FILE = 'notes.md' +const UNTRACKED_LINES = 4 +const STATUS_OUTPUT = [ + '# branch.oid 1111111111111111111111111111111111111111', + '# branch.head (detached)', + '1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts', + `? ${UNTRACKED_FILE}` +].join('\n') + +function isRangedNumstat(args: string[]): boolean { + return args.includes('diff') && args.includes('--numstat') && args.includes('-z') +} + +function rangedDiffCalls(calls: readonly GitCall[]): string[][] { + return calls.map(([args]) => args).filter((args) => isRangedNumstat(args)) +} + +function streamGitFromCapture(git: GitExec): RelayGitStreamExec { + return async (args, cwd, options) => { + const { stdout } = await git(args, cwd, { + disableOptionalLocks: options.disableOptionalLocks, + signal: options.signal + }) + return { stoppedEarly: options.onStdout(stdout) === true } + } +} + +/** Mock host; the fixture-repo cases below run real git instead. */ +function createMockGit(overrides: { + status?: string + ranged?: () => Promise<{ stdout: string; stderr: string }> + areaNumstat?: string +}) { + return vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: overrides.status ?? STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + return overrides.ranged ? overrides.ranged() : { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + } + if (args.includes('diff')) { + return { stdout: overrides.areaNumstat ?? '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) +} + +const realGitExec: GitExec = async (args, cwd, opts) => { + const { stdout, stderr } = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(opts?.timeout ? { timeout: opts.timeout } : {}) + }) + return { stdout, stderr } +} + +function runFixtureGit(repo: string, args: string[]): string { + return execFileSync( + 'git', + [ + '-c', + 'user.email=test@test.com', + '-c', + 'user.name=Test', + '-c', + 'commit.gpgSign=false', + ...args + ], + { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ).trim() +} + +/** + * Fork point → working tree: +3 tracked (2 committed, 1 unstaged), -2 from the + * file the branch deleted, +4 untracked. The pure rename contributes nothing. + */ +async function seedBranchFixture(repo: string): Promise { + await fs.rm(path.join(repo, UNTRACKED_FILE), { force: true }) + execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' }) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\n') + await fs.writeFile(path.join(repo, 'doomed.txt'), 'x\ny\n') + await fs.writeFile(path.join(repo, 'old-name.txt'), 'stable\n') + runFixtureGit(repo, ['add', '.']) + runFixtureGit(repo, ['commit', '-m', 'base']) + const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD']) + + runFixtureGit(repo, ['checkout', '-q', '-b', 'feature']) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\n') + await fs.rm(path.join(repo, 'doomed.txt')) + runFixtureGit(repo, ['add', '-A']) + runFixtureGit(repo, ['commit', '-m', 'branch commit']) + + runFixtureGit(repo, ['mv', 'old-name.txt', 'new-name.txt']) + await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\nf\n') + await fs.writeFile(path.join(repo, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') + return mergeBase +} + +describe('getStatusOp branch line total', () => { + let tmpDir: string + + beforeEach(async () => { + clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-branch-line-total-')) + await fs.writeFile(path.join(tmpDir, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n') + }) + + afterEach(async () => { + clearNoEffectiveUpstreamStatusCache() + clearGitStatusLineStatsCache() + invalidateGitBranchLineTotalInFlight() + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + it('runs no ranged diff and returns no total when the merge base param is absent', async () => { + const git = createMockGit({}) + + const result = await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir }) + + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.branchLineTotal).toBeUndefined() + // The per-area numstats the CHANGES rows need still ran, so this is a + // zero-cost omission rather than a disabled status pass. + expect(result.entries).toContainEqual( + expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 }) + ) + }) + + it('sums the ranged diff and untracked additions against a real fixture repo', async () => { + const mergeBase = await seedBranchFixture(tmpDir) + const git = vi.fn(realGitExec) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: mergeBase + }) + + expect(result.branchLineTotal).toEqual({ + added: 3 + UNTRACKED_LINES, + removed: 2, + mergeBase + }) + expect(rangedDiffCalls(git.mock.calls)).toEqual([ + ['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--'] + ]) + }) + + it('omits the total when the status listing hit its limit', async () => { + const manyEntries = Array.from( + { length: 6 }, + (_, index) => `1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt` + ).join('\n') + const git = createMockGit({ status: manyEntries }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + limit: 3, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.didHitLimit).toBe(true) + // The untracked list is truncated, so any total would silently under-count. + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + }) + + it('omits the total, never zero, when the ranged diff fails', async () => { + const git = createMockGit({ + ranged: () => Promise.reject(new Error('fatal: bad object 0123456789abcdef')) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.branchLineTotal).toBeUndefined() + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + // A failed ranged diff must not take the per-file rows down with it. + expect(result.entries).toContainEqual(expect.objectContaining({ path: 'src/a.ts', added: 3 })) + }) + + it('omits the total, never zero, for a well-formed but unknown merge-base oid', async () => { + await seedBranchFixture(tmpDir) + const git = vi.fn(realGitExec) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + }) + + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(result.entries.length).toBeGreaterThan(0) + }) + + it('omits the total when the ranged diff exceeds its time budget', async () => { + const git = createMockGit({ + ranged: () => { + const error: Error & { killed?: boolean } = new Error('spawn git ETIMEDOUT') + error.killed = true + return Promise.reject(error) + } + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + // The budget is handed to the subprocess rather than raced on a wall clock. + const rangedOptions = git.mock.calls.find(([args]) => isRangedNumstat(args))?.[2] + expect(rangedOptions).toMatchObject({ disableOptionalLocks: true, timeout: expect.any(Number) }) + }) + + it('omits the total when the status scan itself failed', async () => { + const git = vi.fn(async (args) => { + if (args.includes('status')) { + throw new Error('fatal: not a git repository') + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.entries).toEqual([]) + // A failed scan yields an empty untracked list, which would read as "no + // untracked additions" rather than "unknown". + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + }) + + // Relay params are an untyped bag, so the merge base must be proven to be an + // object name before it can be spliced into an argv. + it.each([ + ['a flag-shaped value', '--upload-pack=x'], + ['a rev name', 'HEAD'], + ['a ref path', 'refs/heads/main'], + ['a range', `${MERGE_BASE}..HEAD`], + ['a number', 123], + ['null', null], + ['an empty string', ''] + ])('rejects %s before it reaches a git argv', async (_label, value) => { + const git = createMockGit({}) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: value + }) + + expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false) + expect(rangedDiffCalls(git.mock.calls)).toEqual([]) + for (const [args] of git.mock.calls) { + expect(args).not.toContain(String(value)) + } + }) + + it('answers a rejected merge base with the byte-identical no-merge-base response', async () => { + const gitWithout = createMockGit({}) + const withoutParam = await getStatusOp(gitWithout, streamGitFromCapture(gitWithout), { + worktreePath: tmpDir + }) + clearGitStatusLineStatsCache() + const gitRejected = createMockGit({}) + const rejectedParam = await getStatusOp(gitRejected, streamGitFromCapture(gitRejected), { + worktreePath: tmpDir, + branchLineTotalMergeBase: '--upload-pack=x' + }) + + expect(JSON.stringify(rejectedParam)).toBe(JSON.stringify(withoutParam)) + expect(gitRejected.mock.calls.map(([args]) => args)).toEqual( + gitWithout.mock.calls.map(([args]) => args) + ) + }) + + it('rejects an aborted scan instead of resolving a partial total', async () => { + const controller = new AbortController() + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + controller.abort() + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + throw error + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + await expect( + getStatusOp( + git, + streamGitFromCapture(git), + { worktreePath: tmpDir, branchLineTotalMergeBase: MERGE_BASE }, + { signal: controller.signal } + ) + ).rejects.toThrow(/abort/i) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + }) + + it('reuses the cached total instead of re-running the ranged diff', async () => { + const git = createMockGit({}) + + const first = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + const reused = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE, + reuseLineStats: true + }) + + expect(first.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: MERGE_BASE + }) + expect(reused.branchLineTotal).toEqual(first.branchLineTotal) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + }) + + it('coalesces concurrent status passes onto one ranged diff', async () => { + // The renderer's own in-flight refs only dedupe one renderer; a second + // window or an fs-watcher burst must not run the ranged diff twice. + const git = vi.fn(async (args) => { + if (args.includes('status')) { + return { stdout: STATUS_OUTPUT, stderr: '' } + } + if (isRangedNumstat(args)) { + await new Promise((resolve) => setTimeout(resolve, 20)) + return { stdout: '12\t5\tsrc/a.ts\n', stderr: '' } + } + if (args.includes('diff')) { + return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' } + } + throw new Error(`Unexpected git command: ${args.join(' ')}`) + }) + + const [first, second] = await Promise.all([ + getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }), + getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + ]) + + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1) + expect(first.branchLineTotal).toEqual(second.branchLineTotal) + expect(first.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: MERGE_BASE + }) + }) + + it('recomputes rather than reusing a total measured against another fork point', async () => { + const git = createMockGit({}) + + await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + const moved = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: OTHER_MERGE_BASE, + reuseLineStats: true + }) + + expect(moved.branchLineTotal).toEqual({ + added: 12 + UNTRACKED_LINES, + removed: 5, + mergeBase: OTHER_MERGE_BASE + }) + expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2) + }) + + // Rule 1 of docs/reference/remote-wire-compatibility.md: a new optional field + // is safe only while every reader survives its absence. + describe('wire compatibility', () => { + it('drops the key from the wire payload so a reader sees undefined, not 0 or NaN', async () => { + const git = createMockGit({ ranged: () => Promise.reject(new Error('fatal: bad object')) }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + // An old server's payload and a new server's "not known exact" payload are + // the same thing on the wire: no key at all. + const overTheWire = JSON.parse(JSON.stringify(result)) as { + branchLineTotal?: { added: number; removed: number } + } + + expect(Object.hasOwn(overTheWire, 'branchLineTotal')).toBe(false) + expect(overTheWire.branchLineTotal).toBeUndefined() + expect(overTheWire.branchLineTotal?.added).toBeUndefined() + expect(overTheWire.branchLineTotal?.removed).toBeUndefined() + }) + + it('keeps the new request param out of the status argv an old server would run', async () => { + const git = createMockGit({}) + + await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + const statusArgs = git.mock.calls + .map(([args]) => args) + .filter((args) => args.includes('status')) + expect(statusArgs).toHaveLength(1) + expect(statusArgs[0]).not.toContain(MERGE_BASE) + }) + + it('publishes finite counts when the ranged diff reports only binaries', async () => { + const git = createMockGit({ + ranged: () => Promise.resolve({ stdout: '-\t-\tlogo.png\n', stderr: '' }) + }) + + const result = await getStatusOp(git, streamGitFromCapture(git), { + worktreePath: tmpDir, + branchLineTotalMergeBase: MERGE_BASE + }) + + expect(result.branchLineTotal).toEqual({ + added: UNTRACKED_LINES, + removed: 0, + mergeBase: MERGE_BASE + }) + expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true) + expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true) + }) + }) +}) diff --git a/src/relay/git-status-branch-line-total.ts b/src/relay/git-status-branch-line-total.ts new file mode 100644 index 00000000000..c2fe6556a0e --- /dev/null +++ b/src/relay/git-status-branch-line-total.ts @@ -0,0 +1,44 @@ +/** + * Relay-side wiring for the status pass's branch line total. + * Why: separate file so git-handler-status-ops.ts stays under oxlint max-lines (300). + */ +import { + computeGitBranchLineTotal, + GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS, + type GitBranchLineTotal +} from '../shared/git-branch-line-total' +import type { GitExec } from './git-handler-ops' + +/** Undefined when no valid merge base was requested, which keeps the ranged diff entirely off. */ +export function buildBranchLineTotalInput( + git: GitExec, + worktreePath: string, + entries: Record[], + mergeBase: string | undefined, + signal?: AbortSignal +): { mergeBase: string; compute: () => Promise } | undefined { + if (!mergeBase) { + return undefined + } + return { + mergeBase, + compute: () => + computeGitBranchLineTotal({ + worktreePath, + // Matches the `relay\0` line-stats cache-key convention. + hostKey: 'relay', + mergeBase, + untrackedPaths: entries + .filter((entry) => entry.area === 'untracked') + .map((entry) => entry.path as string), + runDiffNumstat: (args, diffSignal) => + git(args, worktreePath, { + // Why: a working-tree diff must not take index.lock away from terminal Git. + disableOptionalLocks: true, + signal: diffSignal, + timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS + }).then(({ stdout }) => stdout), + ...(signal ? { signal } : {}) + }) + } +} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx new file mode 100644 index 00000000000..c9a7f24beb2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/SourceControl.branch-line-total.test.tsx @@ -0,0 +1,340 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { GitBranchCompareSummary } from '../../../../shared/types' +import { + clearBranchLineTotalRequestGateForTests, + getBranchLineTotalMergeBase +} from './branch-line-total-request-gate' +import SourceControl from './SourceControl' + +const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' + +const mocks = vi.hoisted(() => { + const activeRepo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' as string + } + const activeWorktree = { + id: 'wt-1', + repoId: 'repo-1', + path: '/repo/wt', + head: 'head-1', + branch: 'refs/heads/feature/line-total', + isBare: false, + isMainWorktree: false, + displayName: 'feature/line-total', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + return { + activeRepo, + activeWorktree, + state: {} as Record + } +}) + +vi.mock('@/store', () => { + const useAppStore = Object.assign( + (selector?: (state: Record) => unknown) => + selector ? selector(mocks.state) : mocks.state, + { + getState: () => mocks.state + } + ) + return { useAppStore } +}) + +vi.mock('@/store/selectors', () => ({ + useActiveWorktree: () => mocks.activeWorktree, + useRepoById: (repoId: string | null) => + repoId === mocks.activeRepo.id ? mocks.activeRepo : null, + useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]]) +})) + +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => vi.fn().mockResolvedValue(true) +})) + +vi.mock('./git-status-refresh', () => ({ + refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined), + refreshGitStatusForWorktreeStrict: vi.fn().mockResolvedValue(undefined) +})) + +function noopAsync(value: unknown = undefined): () => Promise { + return vi.fn().mockResolvedValue(value) +} + +const readySummary: GitBranchCompareSummary = { + baseRef: 'refs/remotes/origin/main', + baseOid: 'base-oid', + compareRef: 'feature/line-total', + headOid: 'head-1', + mergeBase: MERGE_BASE, + changedFiles: 2, + commitsAhead: 1, + status: 'ready' +} + +function resetState(overrides: Partial> = {}): void { + vi.clearAllMocks() + mocks.activeRepo.kind = 'git' + mocks.state = { + activeWorktreeId: mocks.activeWorktree.id, + activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' }, + groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] }, + repos: [mocks.activeRepo], + worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] }, + rightSidebarOpen: true, + rightSidebarTab: 'source-control', + gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, + gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: readySummary }, + gitBranchLineTotalByWorktree: {}, + gitConflictOperationByWorktree: {}, + remoteStatusesByWorktree: {}, + isRemoteOperationActive: false, + inFlightRemoteOpKind: null, + settings: null, + hostedReviewCache: {}, + prCache: {}, + commitMessageGenerationRecords: {}, + pullRequestGenerationRecords: {}, + openFiles: [], + activeFileIdByWorktree: {}, + activeTabTypeByWorktree: {}, + getDiffComments: vi.fn(() => []), + updateSettings: noopAsync(), + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn(), + fetchHostedReviewForBranch: noopAsync(), + getHostedReviewCreationEligibility: noopAsync(null), + createHostedReview: noopAsync({ ok: false, error: 'not available' }), + updateWorktreeMeta: noopAsync(), + fetchPRForBranch: noopAsync(), + enqueueGitHubPRRefresh: vi.fn(), + updateRepo: noopAsync(), + setGitStatus: vi.fn(), + updateWorktreeGitIdentity: vi.fn(), + beginGitBranchCompareRequest: vi.fn(() => 'request-key'), + setGitBranchCompareResult: vi.fn(), + clearGitBranchCompare: vi.fn(), + fetchUpstreamStatus: noopAsync(), + setUpstreamStatus: vi.fn(), + pushBranch: noopAsync(), + pullBranch: noopAsync(), + fastForwardBranch: noopAsync(), + syncBranch: noopAsync(), + rebaseFromBase: noopAsync(), + fetchBranch: noopAsync(), + revealInExplorer: vi.fn(), + trackConflictPath: vi.fn(), + openDiff: vi.fn(), + openFile: vi.fn(), + setEditorViewMode: vi.fn(), + setMarkdownViewMode: vi.fn(), + setPendingEditorReveal: vi.fn(), + openConflictFile: vi.fn(), + openConflictReview: vi.fn(), + openBranchDiff: vi.fn(), + createEmptySplitGroup: vi.fn(() => 'group-2'), + openAllDiffs: vi.fn(), + openBranchAllDiffs: vi.fn(), + openCommitAllDiffs: vi.fn(), + deleteDiffComment: noopAsync(true), + clearDiffComments: noopAsync(true), + clearDiffCommentsForFile: noopAsync(true), + setScrollToDiffCommentId: vi.fn(), + setRightSidebarOpen: vi.fn(), + setRightSidebarTab: vi.fn(), + allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'), + setCommitMessageGenerationRecord: vi.fn(), + updateCommitMessageGenerationRecord: vi.fn(), + pruneCommitMessageGenerationRecords: vi.fn(), + allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'), + setPullRequestGenerationRecord: vi.fn(), + updatePullRequestGenerationRecord: vi.fn(), + prunePullRequestGenerationRecords: vi.fn(), + ...overrides + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + clearBranchLineTotalRequestGateForTests() + resetState() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + clearBranchLineTotalRequestGateForTests() +}) + +function renderSourceControl(): void { + act(() => { + root.render( + + + + ) + }) +} + +function chip(): HTMLElement | null { + return container.querySelector('[data-testid="source-control-branch-line-total"]') +} + +describe('SourceControl branch line total request gate', () => { + it('asks for a total when the panel is visible and compare is ready', () => { + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE) + }) + + it('asks for nothing while the sidebar is closed', () => { + resetState({ rightSidebarOpen: false }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing while another right-sidebar tab is showing', () => { + resetState({ rightSidebarTab: 'checks' }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing until branch compare is ready', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'loading' } + } + }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing when compare has no merge base', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'invalid-base' } + } + }) + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('asks for nothing in a folder workspace', () => { + resetState() + mocks.activeRepo.kind = 'folder' + renderSourceControl() + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) + + it('releases the gate when the panel unmounts', () => { + renderSourceControl() + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE) + + act(() => root.render({null})) + + expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined() + }) +}) + +describe('SourceControl branch line total chip', () => { + it('renders a total measured against the current fork point', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()?.getAttribute('aria-label')).toBe('8259 additions, 670 deletions') + // Grouping is pinned to the app locale (`en`), not the runner's host locale. + expect(chip()?.textContent).toBe('+8,259-670') + }) + + it('drops a total whose fork point has since moved', () => { + // Why: status and branch compare refresh on different cadences, so a total + // can outlive the merge base it measured. Hidden beats a stale number. + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: 'stale-merge-base' } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('drops a published total while branch compare has no ready summary', () => { + resetState({ + gitBranchCompareSummaryByWorktree: { + [mocks.activeWorktree.id]: { ...readySummary, status: 'loading', mergeBase: '' } + }, + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('renders nothing when no total was published', () => { + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('renders nothing for an exact zero total', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 0, removed: 0, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()).toBeNull() + }) + + it('omits the zero half of a one-sided total', () => { + resetState({ + gitBranchLineTotalByWorktree: { + [mocks.activeWorktree.id]: { added: 42, removed: 0, mergeBase: MERGE_BASE } + } + }) + renderSourceControl() + + expect(chip()?.textContent).toBe('+42') + expect(chip()?.getAttribute('aria-label')).toBe('42 additions') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx index 8624d25fae2..d3125ee86c7 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.open-file-highlight.test.tsx @@ -104,6 +104,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx index 22bc4954b20..13dc2479894 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.preview-open.test.tsx @@ -156,6 +156,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 68b55f9bda7..68cabc5e044 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -296,6 +296,7 @@ import { } from './source-control-hosted-review-push-target' import { buildSourceControlManualReviewUrlFromContext } from './source-control-manual-review-url' import { parseRemoteRepo } from './source-control-remote-repo' +import { setBranchLineTotalMergeBase } from './branch-line-total-request-gate' export { HostedReviewHeaderLink } from './hosted-review-header-chrome' import { createRunningCommitMessageGenerationRecord, @@ -835,6 +836,15 @@ function SourceControlInner(): React.JSX.Element { const branchSummary = useAppStore((s) => activeWorktreeId ? (s.gitBranchCompareSummaryByWorktree[activeWorktreeId] ?? null) : null ) + const publishedBranchLineTotal = useAppStore((s) => + activeWorktreeId ? (s.gitBranchLineTotalByWorktree?.[activeWorktreeId] ?? null) : null + ) + // Why: status and branch compare refresh on different cadences, so a total can + // outlive the fork point it measured. Drop it rather than render a stale number. + const branchLineTotal = + publishedBranchLineTotal && publishedBranchLineTotal.mergeBase === branchSummary?.mergeBase + ? publishedBranchLineTotal + : null const conflictOperation = useAppStore((s) => activeWorktreeId ? (s.gitConflictOperationByWorktree[activeWorktreeId] ?? 'unknown') : 'unknown' ) @@ -1252,6 +1262,22 @@ function SourceControlInner(): React.JSX.Element { // Why: the sidebar stays mounted when closed, so gate polling on tab AND open or branchCompare/PR fetch would run with no visible consumer. const isBranchVisible = rightSidebarTab === 'source-control' && rightSidebarOpen + // Why: the merge base IS the request gate — no OID on the status request means + // the host runs no ranged diff, so a hidden chip costs a background worktree nothing. + const requestedBranchLineTotalMergeBase = + isBranchVisible && !isFolder && branchSummary?.status === 'ready' + ? branchSummary.mergeBase + : null + useEffect(() => { + if (!activeWorktreeId) { + return + } + setBranchLineTotalMergeBase(activeWorktreeId, requestedBranchLineTotalMergeBase) + return () => { + setBranchLineTotalMergeBase(activeWorktreeId, null) + } + }, [activeWorktreeId, requestedBranchLineTotalMergeBase]) + const refreshActiveGitStatus = useCallback( async (signal?: AbortSignal): Promise => { if (!activeWorktreeId || !worktreePath || isFolder) { @@ -5549,6 +5575,7 @@ function SourceControlInner(): React.JSX.Element { diffCommentCount={diffCommentCount} onExpandNotes={() => setDiffCommentsExpanded(true)} branchSummary={branchSummary} + branchLineTotal={branchLineTotal} compareBaseRef={compareBaseRef} headDisplay={gitIdentityDisplay} upstreamStatus={remoteStatus} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx index 7e1d4c7d2ec..5ff61b17d43 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.virtual-file-list.test.tsx @@ -116,6 +116,7 @@ function resetState(overrides: Partial> = {}): void { gitStatusByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] }, gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null }, + gitBranchLineTotalByWorktree: {}, gitConflictOperationByWorktree: {}, remoteStatusesByWorktree: {}, isRemoteOperationActive: false, diff --git a/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts new file mode 100644 index 00000000000..600a3cf8fc7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + clearBranchLineTotalRequestGateForTests, + getBranchLineTotalMergeBase, + setBranchLineTotalMergeBase +} from './branch-line-total-request-gate' + +describe('branch line total request gate', () => { + beforeEach(() => { + clearBranchLineTotalRequestGateForTests() + }) + + it('reports no merge base for a worktree that never opened the chip', () => { + expect(getBranchLineTotalMergeBase('wt-never-gated')).toBeUndefined() + }) + + it('stores the merge base a visible chip asked for', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + + expect(getBranchLineTotalMergeBase('wt-1')).toBe('merge-base-1') + }) + + it('replaces the merge base when the fork point moves', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', 'merge-base-2') + + expect(getBranchLineTotalMergeBase('wt-1')).toBe('merge-base-2') + }) + + it('deletes the entry when the chip is hidden', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', null) + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + }) + + it('treats an empty merge base as no gate rather than an empty request param', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-1', '') + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + }) + + it('keeps entries independent per worktree', () => { + setBranchLineTotalMergeBase('wt-1', 'merge-base-1') + setBranchLineTotalMergeBase('wt-2', 'merge-base-2') + setBranchLineTotalMergeBase('wt-1', null) + + expect(getBranchLineTotalMergeBase('wt-1')).toBeUndefined() + expect(getBranchLineTotalMergeBase('wt-2')).toBe('merge-base-2') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts new file mode 100644 index 00000000000..c3fa7b98369 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/branch-line-total-request-gate.ts @@ -0,0 +1,23 @@ +// Why: several independent code paths call `git.status`; if only some carried +// the merge base the branch line-total chip would blank out between polls. One +// registry keeps every path asking for the same thing. +// Only the visible worktree's SourceControl is mounted and its effect cleanup +// deletes on every change, so this holds ~1 entry and needs no eviction. +const branchLineTotalMergeBaseByWorktree = new Map() + +/** `null` deletes the entry — that absence is the visibility gate (no merge base ⇒ no host cost). */ +export function setBranchLineTotalMergeBase(worktreeId: string, mergeBase: string | null): void { + if (!mergeBase) { + branchLineTotalMergeBaseByWorktree.delete(worktreeId) + return + } + branchLineTotalMergeBaseByWorktree.set(worktreeId, mergeBase) +} + +export function getBranchLineTotalMergeBase(worktreeId: string): string | undefined { + return branchLineTotalMergeBaseByWorktree.get(worktreeId) +} + +export function clearBranchLineTotalRequestGateForTests(): void { + branchLineTotalMergeBaseByWorktree.clear() +} diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts new file mode 100644 index 00000000000..3437d8d227a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/git-status-refresh-branch-line-total.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearBranchLineTotalRequestGateForTests, + setBranchLineTotalMergeBase +} from './branch-line-total-request-gate' +import { + clearGitStatusRefreshOrderingForTests, + refreshGitStatusForWorktree, + refreshGitStatusForWorktreeStrict, + type GitStatusRefreshDeps +} from './git-status-refresh' +import type { GitStatusResult } from '../../../../shared/types' + +const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c' + +function makeDeps(): GitStatusRefreshDeps { + return { + setGitStatus: vi.fn(), + updateWorktreeGitIdentity: vi.fn(), + setUpstreamStatus: vi.fn(), + fetchUpstreamStatus: vi.fn().mockResolvedValue(null) + } +} + +function stubGitStatus(): ReturnType { + const status: GitStatusResult = { + entries: [], + conflictOperation: 'unknown', + upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } + } + const gitStatus = vi.fn().mockResolvedValue(status) + vi.stubGlobal('window', { + api: { + git: { + status: gitStatus, + cancelStatus: vi.fn().mockResolvedValue(undefined), + upstreamStatus: vi.fn().mockResolvedValue({ hasUpstream: false, ahead: 0, behind: 0 }) + } + } + }) + return gitStatus +} + +describe('branch line total request gate on git status refreshes', () => { + beforeEach(() => { + vi.unstubAllGlobals() + clearGitStatusRefreshOrderingForTests() + clearBranchLineTotalRequestGateForTests() + }) + + it('omits the merge base when no chip is asking for a total', async () => { + const gitStatus = stubGitStatus() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-hidden', + worktreePath: '/repo', + connectionId: 'ssh-1', + deps: makeDeps() + }) + + // Why: no OID on the request is the whole performance contract — the host + // runs no ranged diff, so a background worktree costs nothing. + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1' + }) + }) + + it('omits the merge base for a worktree whose chip was hidden again', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + setBranchLineTotalMergeBase('wt-1', null) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined + }) + }) + + it('does not send one worktree gate on another worktree status pass', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-visible', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-background', + worktreePath: '/other-repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/other-repo', + connectionId: undefined + }) + }) + + it('sends the merge base on a plain refresh that passes no request options', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + // Why: this path used to build no options object at all, so the gate was + // silently dropped on the most common (fs-watcher) refresh. + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1', + branchLineTotalMergeBase: MERGE_BASE + }) + }) + + it('sends the merge base alongside line-stat reuse', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps(), + request: { reuseLineStats: true } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + reuseLineStats: true, + branchLineTotalMergeBase: MERGE_BASE + }) + }) + + it('sends the merge base on an abortable refresh', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + const controller = new AbortController() + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps(), + request: { signal: controller.signal } + }) + + expect(gitStatus).toHaveBeenCalledWith( + expect.objectContaining({ + worktreePath: '/repo', + branchLineTotalMergeBase: MERGE_BASE + }) + ) + }) + + it('re-reads the gate on every pass so a moved fork point is not sent twice', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-1', MERGE_BASE) + + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + setBranchLineTotalMergeBase('wt-1', 'rebased-merge-base') + await refreshGitStatusForWorktree({ + worktreeId: 'wt-1', + worktreePath: '/repo', + deps: makeDeps() + }) + + expect(gitStatus).toHaveBeenNthCalledWith(1, { + worktreePath: '/repo', + connectionId: undefined, + branchLineTotalMergeBase: MERGE_BASE + }) + expect(gitStatus).toHaveBeenNthCalledWith(2, { + worktreePath: '/repo', + connectionId: undefined, + branchLineTotalMergeBase: 'rebased-merge-base' + }) + }) + + it('omits the merge base from a strict refresh when no chip is visible', async () => { + const gitStatus = stubGitStatus() + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps: { ...makeDeps(), fetchUpstreamStatus: undefined } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true + }) + }) + + // Why: a strict refresh is exactly the commit/push/sync that moves the number; + // skipping the gate there would blank the chip until the next automatic poll. + it('sends the merge base on a strict refresh', async () => { + const gitStatus = stubGitStatus() + setBranchLineTotalMergeBase('wt-strict', MERGE_BASE) + + await refreshGitStatusForWorktreeStrict({ + worktreeId: 'wt-strict', + worktreePath: '/repo', + deps: { ...makeDeps(), fetchUpstreamStatus: undefined } + }) + + expect(gitStatus).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + bypassEffectiveUpstreamNegativeCache: true, + branchLineTotalMergeBase: MERGE_BASE + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 596a68f8df3..50f2cf068c7 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -1,4 +1,5 @@ import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client' +import { getBranchLineTotalMergeBase } from './branch-line-total-request-gate' import { clearAutomaticPushTargetUpstreamStatusCache, getCachedAutomaticPushTargetUpstreamStatus, @@ -121,6 +122,9 @@ export async function refreshGitStatusForWorktree({ } }): Promise { const refreshOrder = beginAutomaticUpstreamRefresh(worktreeId) + // Why: every status path must carry the merge base, or the chip blanks out on + // whichever poll happened to omit it. + const branchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId) try { const status = (await getRuntimeGitStatus( { @@ -129,12 +133,11 @@ export async function refreshGitStatusForWorktree({ worktreePath, connectionId }, - request - ? { - ...(request.reuseLineStats === true ? { reuseLineStats: true } : {}), - ...(request.signal ? { signal: request.signal } : {}) - } - : undefined + { + ...(request?.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(request?.signal ? { signal: request.signal } : {}), + ...(branchLineTotalMergeBase ? { branchLineTotalMergeBase } : {}) + } )) as GitStatusResult if (!claimAutomaticUpstreamRefreshApply(worktreeId, refreshOrder, request?.shouldApply)) { @@ -247,6 +250,7 @@ export async function refreshGitStatusForWorktreeStrict({ }): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> { beginStrictUpstreamRefresh(worktreeId) clearAutomaticPushTargetUpstreamStatusCache() + const strictBranchLineTotalMergeBase = getBranchLineTotalMergeBase(worktreeId) const status = (await getRuntimeGitStatus( { settings, @@ -257,7 +261,12 @@ export async function refreshGitStatusForWorktreeStrict({ { // Why: strict refreshes are user-triggered reconciliation and must not reuse // automatic polling's no-upstream backoff window. - bypassEffectiveUpstreamNegativeCache: true + bypassEffectiveUpstreamNegativeCache: true, + // Why: an absent total clears the chip, so a path that skipped the gate + // would blank it right after the commit or push that moved the number. + ...(strictBranchLineTotalMergeBase + ? { branchLineTotalMergeBase: strictBranchLineTotalMergeBase } + : {}) } )) as GitStatusResult diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx index 4a454224997..0172dab5dd0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { describe, expect, it, vi } from 'vitest' import { SourceControlBranchContextRow } from './source-control-branch-context-row' import type { GitBranchCompareSummary } from '../../../../shared/types' +import type { GitBranchLineTotal } from '../../../../shared/git-status-types' vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children: ReactNode }) => <>{children}, @@ -215,3 +216,144 @@ describe('SourceControlBranchContextRow', () => { expect(markup).toContain('aria-label="Open review page in browser"') }) }) + +function renderWithLineTotal( + branchLineTotal: GitBranchLineTotal | null | undefined, + summary: GitBranchCompareSummary | null = readySummary +): string { + return renderToStaticMarkup( + + ) +} + +describe('SourceControlBranchContextRow branch line total', () => { + it('renders both halves with grouped digits and a spoken label', () => { + const markup = renderWithLineTotal({ added: 8259, removed: 670, mergeBase: 'base' }) + + expect(markup).toContain('+8,259') + expect(markup).toContain('-670') + // Label reads raw digits; the grouped spans are decoration. + expect(markup).toContain('aria-label="8259 additions, 670 deletions"') + expect(markup).toContain('tabular-nums') + expect(markup).toContain('text-[color:var(--git-decoration-added)]') + expect(markup).toContain('text-[color:var(--git-decoration-deleted)]') + // Not clickable in v1 — the chip's scope differs from openBranchAllDiffs. + expect(markup).not.toContain(' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 98972cf0760..29218566eb8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14867,7 +14867,7 @@ "other": "Other…", "otherPlaceholder": "Type your answer", "cancel": "Cancel", - "send": "Send answer", + "send": "Submit", "next": "Next", "skip": "Skip", "sending": "Sending…" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 68a9bcb8fbd..e32c1ca2318 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14526,7 +14526,7 @@ "other": "Otro…", "otherPlaceholder": "Escribe tu respuesta", "cancel": "Cancelar", - "send": "Enviar respuesta", + "send": "Enviar", "next": "Siguiente", "skip": "Omitir", "sending": "Enviando…" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9ef5a69cbae..16a92c12c02 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14526,7 +14526,7 @@ "other": "他の…", "otherPlaceholder": "答えを入力してください", "cancel": "キャンセル", - "send": "回答を送信する", + "send": "送信", "next": "次へ", "skip": "スキップ", "sending": "送信中…" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d6c3e34fcd1..0489f18507a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14537,7 +14537,7 @@ "other": "다른…", "otherPlaceholder": "답변을 입력하세요", "cancel": "취소", - "send": "답변 보내기", + "send": "제출", "next": "다음", "skip": "건너뛰기", "sending": "전송 중…" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c5f255fe4e9..1b197b3eaac 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14546,7 +14546,7 @@ "other": "其他…", "otherPlaceholder": "输入你的回答", "cancel": "取消", - "send": "发送回答", + "send": "提交", "next": "下一步", "skip": "跳过", "sending": "正在发送…" diff --git a/tests/e2e/native-chat-ask-user-question-card.spec.ts b/tests/e2e/native-chat-ask-user-question-card.spec.ts index a4cc2d7c4aa..a3788e5aa96 100644 --- a/tests/e2e/native-chat-ask-user-question-card.spec.ts +++ b/tests/e2e/native-chat-ask-user-question-card.spec.ts @@ -146,10 +146,10 @@ test.describe('Desktop chat AskUserQuestion card (#11761)', () => { // The submit button reads "Skip" until an option is chosen; picking one is // what proves the card is answerable rather than merely rendered. await orcaPage.getByRole('button', { name: /Spaces/ }).click() - await expect(orcaPage.getByRole('button', { name: 'Send answer' })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Submit' })).toBeVisible() await orcaPage.screenshot({ path: path.join(screenshotDir, '02-option-selected.png') }) - await orcaPage.getByRole('button', { name: 'Send answer' }).click() + await orcaPage.getByRole('button', { name: 'Submit' }).click() // The card owns the composer slot, so its disappearance is the visible // signal that the answer was accepted and chat input came back. await expect(orcaPage.getByText(QUESTION)).toHaveCount(0, { timeout: 20_000 }) From ae1ed5e886acdc4352e5c2de70fed04331386daa Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:29:46 -0700 Subject: [PATCH 13/46] Remove source control group order preference (#12785) * Reorder source control to show staged changes first by default Stages are closest to the commit action and most relevant to the commit workflow. Merges untracked files into Changes visually while preserving their Git area. Removes the untracked-first preset and includes migration logic for existing user settings. * Drop source control group order user preference Remove the sourceControlGroupOrder setting and related UI, migrations, and persistence logic. The source control view now always displays sections in the order: staged changes, unstaged changes, untracked files. * Reorder source control to show changes before staged Aligns with the edit-stage-commit workflow by showing unstaged changes (active edits) before staged changes (queued for commit). --- .oxlintrc.json | 7 +- .../scripts/relay-watcher-fault-harness.mjs | 22 ++--- .../source-control/mobile-git-status.test.ts | 7 +- .../src/source-control/mobile-git-status.ts | 10 +- .../runtime-home-service.test.ts | 1 - src/main/codex-accounts/service.test.ts | 1 - src/main/persistence.test.ts | 49 +++------- src/main/persistence.ts | 36 +++---- src/main/runtime/orca-runtime.ts | 5 +- .../editor/CombinedDiffFileTree.tsx | 53 ++++++---- .../GitHubMarkdownComposerEditorPane.tsx | 5 +- .../right-sidebar/SourceControl.tsx | 98 +++++++++++++------ .../discard-all-sequence.test.ts | 9 ++ .../right-sidebar/discard-all-sequence.ts | 5 +- ...ource-control-discard-confirmation.test.ts | 9 ++ .../source-control-discard-confirmation.ts | 19 +++- .../source-control-discard-dialog.tsx | 13 ++- .../source-control-section-order.test.ts | 60 ++++++------ .../source-control-section-order.ts | 28 +++--- .../src/components/settings/GitPane.test.ts | 70 +------------ .../src/components/settings/GitPane.tsx | 91 +---------------- .../src/components/settings/git-search.ts | 27 ----- src/renderer/src/i18n/locales/en.json | 19 ++-- src/renderer/src/i18n/locales/es.json | 12 --- src/renderer/src/i18n/locales/ja.json | 12 --- src/renderer/src/i18n/locales/ko.json | 12 --- src/renderer/src/i18n/locales/zh.json | 12 --- src/shared/constants.test.ts | 4 +- src/shared/constants.ts | 2 - src/shared/source-control-group-order.test.ts | 20 ---- src/shared/source-control-group-order.ts | 9 -- src/shared/types.ts | 3 - ...script-prompt-unreadable-orca-yaml.spec.ts | 5 +- 33 files changed, 267 insertions(+), 468 deletions(-) delete mode 100644 src/shared/source-control-group-order.test.ts delete mode 100644 src/shared/source-control-group-order.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 4cdb4f2c0e2..0f679f17d52 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,10 +150,5 @@ } } ], - "ignorePatterns": [ - "**/node_modules", - "**/dist", - "**/out", - "tests/e2e/.cross-version-checkouts" - ] + "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"] } diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index 12e36f72490..b27a1f945d9 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -109,9 +109,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) { settled = true proc.stdout.off('data', onData) rejectPromise( - new Error( - `process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}` - ) + new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) ) } proc.stdout.on('data', onData) @@ -162,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) { }) const waitForMessage = (startIndex, predicate, label) => - pollUntil( - () => messages.slice(startIndex).find(predicate), - label, - streams.stderr - ) + pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) const request = async (method, params = {}) => { const id = nextSequence++ @@ -317,8 +311,10 @@ async function main() { ) await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const firstWatcherPid = await waitForWatcherPid( + pidFile, + undefined, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) const beforePath = join(watchRoot, 'before.txt') startIndex = relay.messageCount() @@ -330,8 +326,10 @@ async function main() { const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' startIndex = relay.messageCount() process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const replacementWatcherPid = await waitForWatcherPid( + pidFile, + firstWatcherPid, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) await relay.waitForNotification(startIndex, 'fs.changed', (params) => Array.isArray(params.events) diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts index a9e2b554f91..007fb317ce9 100644 --- a/mobile/src/source-control/mobile-git-status.test.ts +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -29,11 +29,8 @@ describe('mobile source control status helpers', () => { it('builds sections in the mobile source control order', () => { const sections = buildMobileSourceControlSections(entries) - expect(sections.map((section) => section.title)).toEqual([ - 'Changes', - 'Untracked Files', - 'Staged Changes' - ]) + expect(sections.map((section) => section.title)).toEqual(['Changes', 'Staged Changes']) + expect(sections[0]?.data.map((entry) => entry.path)).toEqual(['a.ts', 'new.ts']) }) it('computes actionable path sets', () => { diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts index 65fcf61e211..2d7b0795918 100644 --- a/mobile/src/source-control/mobile-git-status.ts +++ b/mobile/src/source-control/mobile-git-status.ts @@ -19,7 +19,7 @@ export type MobileSourceControlSection = { unstaged: 'Changes', @@ -59,7 +59,13 @@ export function buildMobileSourceControlSections ({ area, title: AREA_TITLES[area], - data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries) + data: entries + .filter((entry) => + area === 'unstaged' + ? entry.area === 'unstaged' || entry.area === 'untracked' + : entry.area === area + ) + .sort(compareGitStatusEntries) })).filter((section) => section.data.length > 0) } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 8d3a52fa823..f802fecb7e7 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -105,7 +105,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 47fd0c67850..79bd2ea7bbe 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -97,7 +97,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInAppPreferencePrompted: false, rightSidebarOpenByDefault: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: 'changes-first', sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 81faa5f6297..24cc6e512cc 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -676,7 +676,6 @@ describe('Store', () => { const settings = store.getSettings() expect(settings.branchPrefix).toBe('git-username') expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false) - expect(settings.sourceControlGroupOrder).toBe('changes-first') expect(settings.theme).toBe('system') expect(settings.appIcon).toBe('classic') expect(settings.appFontFamily).toBe('Geist') @@ -2784,21 +2783,6 @@ describe('Store', () => { expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first') }) - it('normalizes malformed source control group order on load', async () => { - writeDataFile({ - schemaVersion: 1, - repos: [], - worktreeMeta: {}, - settings: { sourceControlGroupOrder: 'tracked-first' }, - ui: {}, - githubCache: { pr: {}, issue: {} }, - workspaceSession: {} - }) - - const store = await createStore() - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - }) - it('repairs drifted task provider defaults on load', async () => { writeDataFile({ schemaVersion: 1, @@ -5817,15 +5801,21 @@ describe('Store', () => { expect(store.getSettings().sourceControlViewMode).toBe('tree') }) - it('updateSettings persists sourceControlGroupOrder as a user setting', async () => { + it('drops retired Source Control order preferences', async () => { + writeDataFile({ + ...getDefaultPersistedState(testState.dir), + settings: { + ...getDefaultPersistedState(testState.dir).settings, + sourceControlGroupOrder: 'changes-first', + sourceControlHierarchyDefaultedV2: true + } + } as never) + const store = await createStore() - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - - store.updateSettings({ sourceControlGroupOrder: 'staged-first' }) - expect(store.getSettings().sourceControlGroupOrder).toBe('staged-first') - - store.updateSettings({ sourceControlGroupOrder: 'tracked-first' as never }) - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') + store.flush() + const persisted = readDataFile() as { settings?: Record } + expect(persisted.settings).not.toHaveProperty('sourceControlGroupOrder') + expect(persisted.settings).not.toHaveProperty('sourceControlHierarchyDefaultedV2') }) it('updateSettings normalizes terminal shortcut policy', async () => { @@ -5883,18 +5873,15 @@ describe('Store', () => { const store = await createStore() expect(store.getSettings().sourceControlViewMode).toBe('list') - expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first') - - store.updateSettings({ sourceControlViewMode: 'tree', sourceControlGroupOrder: 'staged-first' }) + store.updateSettings({ sourceControlViewMode: 'tree' }) store.flush() const persisted = readDataFile() as { - settings?: { sourceControlGroupOrder?: string; sourceControlViewMode?: string } + settings?: { sourceControlViewMode?: string } workspaceSession?: typeof workspaceSession worktreeMeta?: Record } expect(persisted.settings?.sourceControlViewMode).toBe('tree') - expect(persisted.settings?.sourceControlGroupOrder).toBe('staged-first') expect(persisted.workspaceSession).toEqual({ ...getDefaultWorkspaceSession(), ...workspaceSession @@ -5906,13 +5893,9 @@ describe('Store', () => { expect(collectPropertyPaths(persisted, 'sourceControlViewMode')).toEqual([ 'settings.sourceControlViewMode' ]) - expect(collectPropertyPaths(persisted, 'sourceControlGroupOrder')).toEqual([ - 'settings.sourceControlGroupOrder' - ]) const reloaded = await createStore() expect(reloaded.getSettings().sourceControlViewMode).toBe('tree') - expect(reloaded.getSettings().sourceControlGroupOrder).toBe('staged-first') expect(reloaded.getWorkspaceSession().activeWorktreeId).toBe('repo1::/worktree-a') }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 2afccf7e0ae..af82bcbdd89 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -182,7 +182,6 @@ import { } from '../shared/mobile-pairing-custom-address' import { normalizeOpenInApplications } from '../shared/open-in-applications' import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' -import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order' import { normalizeAppIconId } from '../shared/app-icon' import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes' import { @@ -650,12 +649,22 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal : {} } -function stripLegacyTerminalScrollbackBytes( +function stripRetiredSettingsFields( settings: Partial | undefined ): Partial { - const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ?? - {}) as Partial & { terminalScrollbackBytes?: unknown } + const { + terminalScrollbackBytes: _legacyScrollbackBytes, + sourceControlGroupOrder: _sourceControlGroupOrder, + sourceControlHierarchyDefaultedV2: _sourceControlHierarchyDefaultedV2, + ...rest + } = (settings ?? {}) as Partial & { + terminalScrollbackBytes?: unknown + sourceControlGroupOrder?: unknown + sourceControlHierarchyDefaultedV2?: unknown + } void _legacyScrollbackBytes + void _sourceControlGroupOrder + void _sourceControlHierarchyDefaultedV2 return rest } @@ -3316,15 +3325,6 @@ export class Store { ) { this.loadNeedsSave = true } - const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder( - parsed.settings?.sourceControlGroupOrder - ) - if ( - parsed.settings?.sourceControlGroupOrder !== undefined && - parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder - ) { - this.loadNeedsSave = true - } result = { ...defaults, ...parsed, @@ -3346,7 +3346,7 @@ export class Store { settings: { ...defaults.settings, // Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed. - ...stripLegacyTerminalScrollbackBytes(parsed.settings), + ...stripRetiredSettingsFields(parsed.settings), prBotAuthorOverrides: normalizePRBotAuthorOverrides( parsed.settings?.prBotAuthorOverrides ), @@ -3420,7 +3420,6 @@ export class Store { }), notifications: normalizeNotificationSettings(parsed.settings?.notifications), sourceControlAi: migratedSourceControlAi, - sourceControlGroupOrder: normalizedSourceControlGroupOrder, // Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat. commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi( migratedSourceControlAi, @@ -5691,7 +5690,7 @@ export class Store { updates: Partial, options: { notifyListeners?: boolean; originWebContentsId?: number } = {} ): GlobalSettings { - const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates) + const sanitizedUpdates = stripRetiredSettingsFields(updates) // Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on". if ('minimizeToTrayOnClose' in updates) { sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true @@ -5759,11 +5758,6 @@ export class Store { updates.terminalShortcutPolicy ) } - if ('sourceControlGroupOrder' in updates) { - sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder( - updates.sourceControlGroupOrder - ) - } if ('appIcon' in updates) { sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon) } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5e23498f370..b85bb01b463 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10762,10 +10762,7 @@ export class OrcaRuntimeService { // A spawn published (or admission pending) this generation already // attaches the provider stream; a replacement under a reused id must not // read as the discovered never-attached session it replaced. - if ( - this.spawnPublishedPtys.has(ptyId) || - this.pendingPtyRegistrationIncarnations.has(ptyId) - ) { + if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) { return false } // SSH panes have their own lease/reattach machinery. diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx index fc59c2dd75c..dd6f7b99b93 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/CombinedDiffFileTree.tsx @@ -10,7 +10,7 @@ import { compactSourceControlTree, flattenSourceControlTree } from '@/components/right-sidebar/source-control-tree' -import type { GitBranchChangeEntry, GitStagingArea, GitStatusEntry } from '../../../../shared/types' +import type { GitBranchChangeEntry, GitStagingArea } from '../../../../shared/types' import { getEntryExtension, getFilteredCombinedDiffFileTreeEntries, @@ -22,6 +22,11 @@ import { import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row' import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize' import { translate } from '@/i18n/i18n' +import { + mergeUntrackedIntoChanges, + SOURCE_CONTROL_GROUP_ORDER, + type SourceControlEntryGroups +} from '@/components/right-sidebar/source-control-section-order' export { createCombinedDiffSectionIndexMap, @@ -30,7 +35,6 @@ export { handleCombinedDiffFileTreeNavigation } from './combined-diff-file-tree-model' -const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked'] const UNCOMMITTED_AREA_LABELS: Record = { unstaged: 'Changes', staged: 'Staged Changes', @@ -39,26 +43,35 @@ const UNCOMMITTED_AREA_LABELS: Record = { function buildUncommittedRows( entries: readonly CombinedDiffFileTreeEntry[], - collapsedDirectoryKeys: ReadonlySet + collapsedDirectoryKeys: ReadonlySet, + areaOrder: readonly GitStagingArea[] ): { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] }[] { - return UNCOMMITTED_AREA_ORDER.map((area) => { - const areaEntries = entries.filter( - (entry): entry is GitStatusEntry => isGitStatusEntry(entry) && entry.area === area - ) - if (areaEntries.length === 0) { - return null + const groups: SourceControlEntryGroups = { staged: [], unstaged: [], untracked: [] } + for (const entry of entries) { + if (isGitStatusEntry(entry)) { + groups[entry.area].push(entry) } + } + const displayGroups = mergeUntrackedIntoChanges(groups) - const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries)) - return { - area, - label: UNCOMMITTED_AREA_LABELS[area], - rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] - } - }).filter( - (group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } => - Boolean(group) - ) + return areaOrder + .map((area) => { + const areaEntries = displayGroups[area] + if (areaEntries.length === 0) { + return null + } + + const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries)) + return { + area, + label: UNCOMMITTED_AREA_LABELS[area], + rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[] + } + }) + .filter( + (group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } => + Boolean(group) + ) } function buildBranchRows( @@ -153,7 +166,7 @@ export function CombinedDiffFileTree({ const uncommittedGroups = React.useMemo( () => mode === 'all' || mode === 'uncommitted' - ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys) + ? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys, SOURCE_CONTROL_GROUP_ORDER) : [], [collapsedDirectoryKeys, filteredEntries, mode] ) diff --git a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx index 6914f9b3aba..792799376ea 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposerEditorPane.tsx @@ -13,7 +13,10 @@ export function GitHubMarkdownComposerEditorPane({ const scrollContainerRef = useRef(null) return ( -
+
entry.area === 'untracked') } } @@ -1060,7 +1067,6 @@ function SourceControlInner(): React.JSX.Element { settings?.sourceControlViewMode ) const sourceControlViewMode = persistedSourceControlViewMode - const sourceControlGroupOrder = resolveSourceControlGroupOrder(settings?.sourceControlGroupOrder) const [collapsedTreeDirs, setCollapsedTreeDirs] = useState>(new Set()) const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false) const [pendingDiscard, setPendingDiscard] = useState(null) @@ -1796,13 +1802,19 @@ function SourceControlInner(): React.JSX.Element { [fileFilterState, grouped] ) + const mergedGrouped = useMemo(() => mergeUntrackedIntoChanges(grouped), [grouped]) + const mergedFilteredGrouped = useMemo( + () => mergeUntrackedIntoChanges(filteredGrouped), + [filteredGrouped] + ) + const displaySections = useMemo( - () => buildSourceControlDisplaySections(filteredGrouped, sourceControlGroupOrder), - [filteredGrouped, sourceControlGroupOrder] + () => buildSourceControlDisplaySections(mergedFilteredGrouped, SOURCE_CONTROL_GROUP_ORDER), + [mergedFilteredGrouped] ) const unfilteredDisplaySections = useMemo( - () => buildSourceControlDisplaySections(grouped, sourceControlGroupOrder), - [grouped, sourceControlGroupOrder] + () => buildSourceControlDisplaySections(mergedGrouped, SOURCE_CONTROL_GROUP_ORDER), + [mergedGrouped] ) const unfilteredDisplaySectionsById = useMemo( () => new Map(unfilteredDisplaySections.map((section) => [section.id, section])), @@ -5400,7 +5412,9 @@ function SourceControlInner(): React.JSX.Element { if (!worktreePath || !activeWorktreeId || isExecutingBulk) { return } - const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area) + const paths = confirmedPaths + ? [...confirmedPaths] + : getDiscardAllPaths(mergedGrouped[area], discardAllAreaFilter(area)) if (paths.length === 0) { return } @@ -5472,7 +5486,7 @@ function SourceControlInner(): React.JSX.Element { activeRepoSettings, worktreePath, activeWorktreeId, - grouped, + mergedGrouped, isExecutingBulk, clearSelection, discardMany, @@ -5486,13 +5500,21 @@ function SourceControlInner(): React.JSX.Element { if (!worktreePath || !activeWorktreeId || isExecutingBulk) { return } - const paths = confirmedPaths ? [...confirmedPaths] : getDiscardAllPaths(grouped[area], area) + const paths = confirmedPaths + ? [...confirmedPaths] + : getDiscardAllPaths(mergedGrouped[area], discardAllAreaFilter(area)) if (paths.length === 0) { return } - setPendingDiscard({ kind: 'area', area, paths }) + setPendingDiscard({ + kind: 'area', + area, + paths, + hasUntracked: + area === 'unstaged' && mergedGrouped.unstaged.some((entry) => entry.area === 'untracked') + }) }, - [activeWorktreeId, grouped, isExecutingBulk, worktreePath] + [activeWorktreeId, isExecutingBulk, mergedGrouped, worktreePath] ) const requestDiscardEntry = useCallback( @@ -5914,7 +5936,8 @@ function SourceControlInner(): React.JSX.Element { .filter(isStageableStatusEntry) .map((entry) => entry.path) const unstageAllPaths = getUnstageAllPaths(actionItems) - const discardAllPaths = getDiscardAllPaths(actionItems, area) + const discardAllPaths = getDiscardAllPaths(actionItems, discardAllAreaFilter(area)) + const discardHasUntracked = actionItems.some((entry) => entry.area === 'untracked') const canStageAll = !normalizedFilter && stageAllPaths.length > 0 const canUnstageAll = !normalizedFilter && unstageAllPaths.length > 0 const canRevertAll = !normalizedFilter && discardAllPaths.length > 0 @@ -5937,7 +5960,7 @@ function SourceControlInner(): React.JSX.Element {
{canRevertAll && ( { event.stopPropagation() @@ -6055,11 +6083,12 @@ function SourceControlInner(): React.JSX.Element { isExecutingBulk={isExecutingBulk} isCollapsed={collapsedTreeDirs.has(node.key)} onToggle={() => toggleTreeDir(node.key)} - onRequestDiscardPaths={(discardArea, paths) => + onRequestDiscardPaths={(discardArea, paths, hasUntracked) => setPendingDiscard({ kind: 'area', area: discardArea, - paths + paths, + hasUntracked }) } onStagePaths={handleStageAllPaths} @@ -7698,7 +7727,11 @@ function SourceControlTreeDirectoryRow({ isExecutingBulk: boolean isCollapsed: boolean onToggle: () => void - onRequestDiscardPaths: (area: DiscardAllArea, paths: readonly string[]) => void + onRequestDiscardPaths: ( + area: DiscardAllArea, + paths: readonly string[], + hasUntracked: boolean + ) => void onStagePaths: (paths: readonly string[]) => Promise onUnstagePaths: (paths: readonly string[]) => Promise }): React.JSX.Element { @@ -7737,21 +7770,30 @@ function SourceControlTreeDirectoryRow({
{canDiscard && ( { event.stopPropagation() - onRequestDiscardPaths(node.area, actionPaths.discardPaths) + onRequestDiscardPaths( + node.area, + actionPaths.discardPaths, + actionPaths.discardHasUntracked + ) }} disabled={isExecutingBulk} /> diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts index e808af6da27..d8f76c48f3f 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.test.ts @@ -30,6 +30,15 @@ describe('getDiscardAllPaths', () => { expect(getDiscardAllPaths(entries, 'untracked')).toEqual(['c.ts']) }) + it('accepts mixed Changes areas when untracked files are combined', () => { + const entries: GitStatusEntry[] = [ + entry({ path: 'changed.ts', area: 'unstaged' }), + entry({ path: 'new.ts', area: 'untracked', status: 'untracked' }), + entry({ path: 'ready.ts', area: 'staged' }) + ] + expect(getDiscardAllPaths(entries, ['unstaged', 'untracked'])).toEqual(['changed.ts', 'new.ts']) + }) + it('skips entries with an unresolved conflict', () => { const entries: GitStatusEntry[] = [ entry({ path: 'clean.ts', area: 'unstaged' }), diff --git a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts index c52854fcd91..073ba321a2a 100644 --- a/src/renderer/src/components/right-sidebar/discard-all-sequence.ts +++ b/src/renderer/src/components/right-sidebar/discard-all-sequence.ts @@ -9,12 +9,13 @@ export type DiscardAllArea = 'staged' | 'unstaged' | 'untracked' */ export function getDiscardAllPaths( entries: readonly GitStatusEntry[], - area: DiscardAllArea + area: DiscardAllArea | readonly DiscardAllArea[] ): string[] { + const areas = Array.isArray(area) ? area : [area] return entries .filter( (entry) => - entry.area === area && + areas.includes(entry.area) && entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally' ) diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts index 48e28160af6..6f6632feea8 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.test.ts @@ -111,4 +111,13 @@ describe('getDiscardAreaConfirmationCopy', () => { confirmLabel: 'Discard all' }) }) + + it('discloses permanent deletion for combined Changes', () => { + expect(getDiscardAreaConfirmationCopy('unstaged', 3, true)).toEqual({ + title: 'Discard changes and delete untracked files?', + description: + 'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.', + confirmLabel: 'Delete and discard' + }) + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts index ff020c434ec..03a6805e7f0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts +++ b/src/renderer/src/components/right-sidebar/source-control-discard-confirmation.ts @@ -62,8 +62,25 @@ export function getDiscardEntryConfirmationCopy( export function getDiscardAreaConfirmationCopy( area: DiscardAllArea, - count: number + count: number, + hasUntracked = false ): DiscardConfirmationCopy { + if (area === 'unstaged' && hasUntracked) { + return { + title: translate( + 'auto.components.right.sidebar.source.control.discard.confirmation.mixedTitle', + 'Discard changes and delete untracked files?' + ), + description: translate( + 'auto.components.right.sidebar.source.control.discard.confirmation.mixedDescription', + 'Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.' + ), + confirmLabel: translate( + 'auto.components.right.sidebar.source.control.discard.confirmation.mixedConfirm', + 'Delete and discard' + ) + } + } switch (area) { case 'untracked': return { diff --git a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx index 6741a72ac55..25e2366b616 100644 --- a/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-discard-dialog.tsx @@ -20,7 +20,12 @@ import { translate } from '@/i18n/i18n' export type PendingDiscardConfirmation = | { kind: 'entry'; entry: GitStatusEntry } - | { kind: 'area'; area: DiscardAllArea; paths: readonly string[] } + | { + kind: 'area' + area: DiscardAllArea + paths: readonly string[] + hasUntracked?: boolean + } export function focusDiscardDialogConfirmButton( event: Event, @@ -51,7 +56,11 @@ export function SourceControlDiscardDialog({ if (pendingDiscard.kind === 'entry') { return getDiscardEntryConfirmationCopy(pendingDiscard.entry) } - return getDiscardAreaConfirmationCopy(pendingDiscard.area, pendingDiscard.paths.length) + return getDiscardAreaConfirmationCopy( + pendingDiscard.area, + pendingDiscard.paths.length, + pendingDiscard.hasUntracked + ) }, [pendingDiscard]) const PendingDiscardIcon = pendingDiscardCopy?.confirmLabel.startsWith('Delete') ? Trash : Undo2 diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts index fa563470d66..91378833b0d 100644 --- a/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.test.ts @@ -4,7 +4,8 @@ import { buildSourceControlDisplaySections, getConflictReviewEntries, getSourceControlSectionViewAction, - resolveSourceControlGroupOrder, + mergeUntrackedIntoChanges, + SOURCE_CONTROL_GROUP_ORDER, splitPinnedSourceControlConflicts, type SourceControlEntryGroups } from './source-control-section-order' @@ -26,37 +27,38 @@ function groups(partial: Partial): SourceControlEntryG } } -describe('resolveSourceControlGroupOrder', () => { - it('keeps Changes first by default', () => { - expect(resolveSourceControlGroupOrder(undefined)).toEqual(['unstaged', 'staged', 'untracked']) +describe('SOURCE_CONTROL_GROUP_ORDER', () => { + it('follows the edit, stage, commit workflow', () => { + expect(SOURCE_CONTROL_GROUP_ORDER).toEqual(['unstaged', 'staged', 'untracked']) }) +}) - it('supports staged-first and untracked-first presets', () => { - expect(resolveSourceControlGroupOrder('staged-first')).toEqual([ - 'staged', - 'unstaged', - 'untracked' - ]) - expect(resolveSourceControlGroupOrder('untracked-first')).toEqual([ - 'untracked', - 'unstaged', - 'staged' - ]) +describe('mergeUntrackedIntoChanges', () => { + it('folds untracked entries into Changes without changing their Git area', () => { + const unstaged = entry({ area: 'unstaged', path: 'changed.ts' }) + const untracked = entry({ area: 'untracked', path: 'new.ts', status: 'untracked' }) + const merged = mergeUntrackedIntoChanges( + groups({ unstaged: [unstaged], untracked: [untracked] }) + ) + + expect(merged.unstaged).toEqual([unstaged, untracked]) + expect(merged.untracked).toEqual([]) + expect(merged.unstaged[1]?.area).toBe('untracked') }) }) describe('buildSourceControlDisplaySections', () => { - it('uses the configured order for normal sections', () => { + it('uses the fixed workflow order for normal sections', () => { const sections = buildSourceControlDisplaySections( groups({ staged: [entry({ area: 'staged', path: 'staged.ts' })], unstaged: [entry({ area: 'unstaged', path: 'changed.ts' })], untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })] }), - resolveSourceControlGroupOrder('staged-first') + SOURCE_CONTROL_GROUP_ORDER ) - expect(sections.map((section) => section.id)).toEqual(['staged', 'unstaged', 'untracked']) + expect(sections.map((section) => section.id)).toEqual(['unstaged', 'staged', 'untracked']) }) it('keeps conflicts pinned before the configured normal order', () => { @@ -74,13 +76,13 @@ describe('buildSourceControlDisplaySections', () => { ], untracked: [entry({ area: 'untracked', path: 'new.ts', status: 'untracked' })] }), - resolveSourceControlGroupOrder('staged-first') + SOURCE_CONTROL_GROUP_ORDER ) expect(sections.map((section) => section.id)).toEqual([ 'conflicts', - 'staged', 'unstaged', + 'staged', 'untracked' ]) }) @@ -100,10 +102,7 @@ describe('buildSourceControlDisplaySections', () => { const input = groups({ unstaged: [unresolved, resolved, normal] }) const split = splitPinnedSourceControlConflicts(input) - const sections = buildSourceControlDisplaySections( - input, - resolveSourceControlGroupOrder('changes-first') - ) + const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER) expect(split.pinnedConflicts.map((item) => item.path)).toEqual(['conflict.ts', 'resolved.ts']) expect(split.normalGroups.unstaged.map((item) => item.path)).toEqual(['normal.ts']) @@ -123,10 +122,7 @@ describe('buildSourceControlDisplaySections', () => { const input = groups({ staged: [resolvedStaged, staged] }) const split = splitPinnedSourceControlConflicts(input) - const sections = buildSourceControlDisplaySections( - input, - resolveSourceControlGroupOrder('staged-first') - ) + const sections = buildSourceControlDisplaySections(input, SOURCE_CONTROL_GROUP_ORDER) expect(split.pinnedConflicts).toEqual([resolvedStaged]) expect(split.normalGroups.staged).toEqual([staged]) @@ -167,7 +163,7 @@ describe('buildSourceControlDisplaySections', () => { entry({ area: 'unstaged', path: 'normal.ts' }) ] }), - resolveSourceControlGroupOrder('changes-first') + SOURCE_CONTROL_GROUP_ORDER ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ @@ -191,7 +187,7 @@ describe('buildSourceControlDisplaySections', () => { const normal = entry({ area: 'unstaged', path: 'normal.ts' }) const sections = buildSourceControlDisplaySections( groups({ unstaged: [pinned, normal] }), - resolveSourceControlGroupOrder('changes-first') + SOURCE_CONTROL_GROUP_ORDER ) expect(getSourceControlSectionViewAction(sections[1]!)).toEqual({ @@ -212,7 +208,7 @@ describe('buildSourceControlDisplaySections', () => { groups({ unstaged: [resolved] }), - resolveSourceControlGroupOrder('changes-first') + SOURCE_CONTROL_GROUP_ORDER ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ @@ -240,7 +236,7 @@ describe('buildSourceControlDisplaySections', () => { staged: [staged], unstaged: [unstaged] }), - resolveSourceControlGroupOrder('staged-first') + SOURCE_CONTROL_GROUP_ORDER ) expect(getSourceControlSectionViewAction(sections[0]!)).toEqual({ diff --git a/src/renderer/src/components/right-sidebar/source-control-section-order.ts b/src/renderer/src/components/right-sidebar/source-control-section-order.ts index 557db90b556..91cc948855b 100644 --- a/src/renderer/src/components/right-sidebar/source-control-section-order.ts +++ b/src/renderer/src/components/right-sidebar/source-control-section-order.ts @@ -1,5 +1,4 @@ -import { normalizeSourceControlGroupOrder } from '../../../../shared/source-control-group-order' -import type { GitStatusEntry, SourceControlGroupOrder } from '../../../../shared/types' +import type { GitStatusEntry } from '../../../../shared/types' export const SOURCE_CONTROL_AREAS = ['unstaged', 'staged', 'untracked'] as const export type SourceControlSectionArea = (typeof SOURCE_CONTROL_AREAS)[number] @@ -22,16 +21,23 @@ export type SourceControlSectionViewAction = | { kind: 'conflict-review'; entries: SourceControlConflictReviewEntry[] } | { kind: 'combined-diff'; area?: SourceControlSectionArea; entries: GitStatusEntry[] } -const ORDER_BY_PRESET: Record = { - 'changes-first': ['unstaged', 'staged', 'untracked'], - 'staged-first': ['staged', 'unstaged', 'untracked'], - 'untracked-first': ['untracked', 'unstaged', 'staged'] -} +export const SOURCE_CONTROL_GROUP_ORDER: readonly SourceControlSectionArea[] = [ + 'unstaged', + 'staged', + 'untracked' +] -export function resolveSourceControlGroupOrder( - value: SourceControlGroupOrder | null | undefined -): readonly SourceControlSectionArea[] { - return ORDER_BY_PRESET[normalizeSourceControlGroupOrder(value)] +export function mergeUntrackedIntoChanges( + groups: SourceControlEntryGroups +): SourceControlEntryGroups { + if (groups.untracked.length === 0) { + return groups + } + return { + staged: groups.staged, + unstaged: [...groups.unstaged, ...groups.untracked], + untracked: [] + } } export function isPinnedConflictEntry(entry: GitStatusEntry): boolean { diff --git a/src/renderer/src/components/settings/GitPane.test.ts b/src/renderer/src/components/settings/GitPane.test.ts index 3275eb9a183..2947f866fdd 100644 --- a/src/renderer/src/components/settings/GitPane.test.ts +++ b/src/renderer/src/components/settings/GitPane.test.ts @@ -6,12 +6,7 @@ import { getDefaultSettings } from '../../../../shared/constants' import { translate } from '../../i18n/i18n' import { useAppStore } from '../../store' import { shouldOpenAutoRenameBranchAdvanced } from './AutoRenameBranchFromWorkSetting' -import { - GitPane, - SourceControlGroupOrderSetting, - getGitPaneSearchEntries, - shouldShowAutoRenameBranchSetting -} from './GitPane' +import { GitPane, getGitPaneSearchEntries, shouldShowAutoRenameBranchSetting } from './GitPane' import { TooltipProvider } from '../ui/tooltip' import { matchesSettingsSearch } from './settings-search' import { SettingsSegmentedControl } from './SettingsFormControls' @@ -40,23 +35,6 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void { } } -function findSegmentedControl(node: unknown): ReactElementLike { - let found: ReactElementLike | null = null - const label = translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ) - visit(node, (entry) => { - if (entry.type === SettingsSegmentedControl && entry.props.ariaLabel === label) { - found = entry - } - }) - if (!found) { - throw new Error('segmented control not found') - } - return found -} - function findCompareBaseSegmentedControl(node: unknown): ReactElementLike { let found: ReactElementLike | null = null const label = translate( @@ -136,52 +114,6 @@ describe('GitPane', () => { expect(markup).not.toContain('Refresh Local Base Ref') }) - it('renders Source Control group order in Git settings', () => { - const markup = renderGitPane('group order') - - expect(markup).toContain( - translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ) - ) - expect(markup).toContain( - translate('auto.components.settings.GitPane.changesFirst', 'Changes first') - ) - expect(markup).toContain( - translate('auto.components.settings.GitPane.stagedFirst', 'Staged first') - ) - expect(markup).toContain( - translate('auto.components.settings.GitPane.untrackedFirst', 'Untracked first') - ) - }) - - it('updates Source Control group order only when the selected option changes', () => { - const updateSettings = vi.fn() - const element = SourceControlGroupOrderSetting({ - settings: { - ...getDefaultSettings(os.homedir()), - sourceControlGroupOrder: 'changes-first' - }, - updateSettings - }) - - const control = findSegmentedControl(element) - const onChange = control.props.onChange as (value: string) => void - - onChange('staged-first') - expect(updateSettings).toHaveBeenCalledWith({ sourceControlGroupOrder: 'staged-first' }) - - updateSettings.mockClear() - onChange('changes-first') - expect(updateSettings).not.toHaveBeenCalled() - }) - - it('includes Source Control group order search metadata', () => { - expect(matchesSettingsSearch('staged', getGitPaneSearchEntries())).toBe(true) - expect(matchesSettingsSearch('group order', getGitPaneSearchEntries())).toBe(true) - }) - it('renders the default compare base setting in Git settings', () => { const markup = renderGitPane('compare base') diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index da1a34c3fe8..538315e3e7d 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import type { GlobalSettings, SourceControlGroupOrder } from '../../../../shared/types' +import type { GlobalSettings } from '../../../../shared/types' import type { SourceControlAiSettingsPatch } from '../../../../shared/source-control-ai-types' -import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from '../../../../shared/source-control-group-order' import { Input } from '../ui/input' import { Label } from '../ui/label' import { useAppStore } from '../../store' @@ -20,7 +19,6 @@ import { getKeepLocalMainUpToDateTitle } from './keep-local-main-up-to-date-setting' import { translate } from '@/i18n/i18n' -import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' export { getGitPaneSearchEntries } @@ -40,14 +38,6 @@ const KEEP_LOCAL_MAIN_UP_TO_DATE_KEYWORDS = [ 'safely', 'worktree' ] -const SOURCE_CONTROL_GROUP_ORDER_KEYWORDS = [ - 'group order', - 'changes first', - 'staged first', - 'untracked first', - 'source control', - 'git changes' -] export function shouldShowAutoRenameBranchSetting( searchQuery: string, @@ -70,68 +60,6 @@ type GitPaneProps = { settingsSearchQuery?: string } -export function SourceControlGroupOrderSetting({ - settings, - updateSettings -}: { - settings: GlobalSettings - updateSettings: (updates: Partial) => void | Promise -}): React.JSX.Element { - const value = settings.sourceControlGroupOrder ?? DEFAULT_SOURCE_CONTROL_GROUP_ORDER - const title = translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ) - const description = translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderDescription', - 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' - ) - - return ( - - - value={value} - onChange={(nextValue) => { - if (nextValue !== value) { - void updateSettings({ sourceControlGroupOrder: nextValue }) - } - }} - ariaLabel={title} - size="sm" - options={[ - { - value: 'changes-first', - label: translate('auto.components.settings.GitPane.changesFirst', 'Changes first') - }, - { - value: 'staged-first', - label: translate('auto.components.settings.GitPane.stagedFirst', 'Staged first') - }, - { - value: 'untracked-first', - label: translate( - 'auto.components.settings.GitPane.untrackedFirst', - 'Untracked first' - ) - } - ]} - /> - } - /> - - ) -} - export function GitPane({ settings, updateSettings, @@ -297,23 +225,6 @@ export function GitPane({ ) : null, - matchesSettingsSearch(searchQuery, { - title: translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ), - description: translate( - 'auto.components.settings.GitPane.sourceControlGroupOrderDescription', - 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' - ), - keywords: SOURCE_CONTROL_GROUP_ORDER_KEYWORDS - }) ? ( - - ) : null, compareAgainstUpstreamMatchesSearch(searchQuery) ? ( [ ...translateSearchKeyword('auto.components.settings.git.search.035134fcd9', 'worktree') ] }, - { - title: translate( - 'auto.components.settings.git.search.sourceControlGroupOrderTitle', - 'Source Control Group Order' - ), - description: translate( - 'auto.components.settings.git.search.sourceControlGroupOrderDescription', - 'Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.git.search.groupOrder', 'group order'), - ...translateSearchKeyword( - 'auto.components.settings.git.search.changesFirst', - 'changes first' - ), - ...translateSearchKeyword('auto.components.settings.git.search.stagedFirst', 'staged first'), - ...translateSearchKeyword( - 'auto.components.settings.git.search.untrackedFirst', - 'untracked first' - ), - ...translateSearchKeyword( - 'auto.components.settings.git.search.sourceControl', - 'source control' - ), - ...translateSearchKeyword('auto.components.settings.git.search.gitChanges', 'git changes') - ] - }, { title: translate( 'auto.components.settings.git.search.compareAgainstUpstreamTitle', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 29218566eb8..3f940076736 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6183,11 +6183,6 @@ "3d172725cc": "None", "1f32ba27a6": "Custom", "a182c5125e": "Git Username", - "sourceControlGroupOrderTitle": "Source Control Group Order", - "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.", - "changesFirst": "Changes first", - "stagedFirst": "Staged first", - "untrackedFirst": "Untracked first", "compareAgainstUpstreamTitle": "Default Compare Base", "compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.", "compareBaseRepositoryDefault": "Repository default", @@ -8463,14 +8458,7 @@ "f83c8937c4": "branch naming", "5ecd91c5ef": "Prefix added to branch names when creating worktrees.", "68bd65fdb8": "Branch Prefix", - "sourceControlGroupOrderTitle": "Source Control Group Order", - "sourceControlGroupOrderDescription": "Choose whether Changes, Staged Changes, or Untracked Files appear first in Source Control.", - "groupOrder": "group order", - "changesFirst": "changes first", - "stagedFirst": "staged first", - "untrackedFirst": "untracked first", "sourceControl": "source control", - "gitChanges": "git changes", "compareAgainstUpstreamTitle": "Default Compare Base", "compareAgainstUpstreamDescription": "Choose which base Source Control uses by default for committed-change comparisons. Branch upstream follows the current branch automatically and falls back to the repository default branch when no upstream exists. You can still change the compare base per worktree from that worktree's Git panel. Pull Request and rebase targets don't change.", "compareBase": "compare base", @@ -10805,6 +10793,8 @@ "a4e93c21d7": "Current branch: {{value0}}", "c7d4e2f801": "Change base ref: {{value0}}", "f3a1b8c204": "upstream", + "discardAllMixedUntracked": "Discard changes and delete untracked files", + "discardFolderMixedUntracked": "Discard changes and delete untracked files in folder", "createPrIntentEmptyGeneratedBody": "Generated review details did not include a description. Retry Create PR.", "createPrIntentGenerateDetailsFailed": "Could not generate review details. Retry Create PR." }, @@ -11230,7 +11220,10 @@ "40e9357b2a": "This will restore the file from HEAD and discard the deletion. This cannot be undone.", "5c0bdbc4cb": "Restore \"{{value0}}\"?", "d97bf697c9": "This will permanently delete this file. This cannot be undone.", - "96c772bee9": "Delete \"{{value0}}\"?" + "96c772bee9": "Delete \"{{value0}}\"?", + "mixedTitle": "Discard changes and delete untracked files?", + "mixedDescription": "Tracked changes will be reverted and untracked files will be permanently deleted. This cannot be undone.", + "mixedConfirm": "Delete and discard" }, "dialog": { "3bc61dc989": "Cancel", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e32c1ca2318..33b14ec6fa7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -5966,11 +5966,6 @@ "3d172725cc": "Ninguno", "1f32ba27a6": "Personalizado", "a182c5125e": "Usuario de Git", - "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", - "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", - "changesFirst": "Cambios primero", - "stagedFirst": "Cambios preparados primero", - "untrackedFirst": "Archivos sin seguimiento primero", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", "compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.", "compareBaseRepositoryDefault": "Predeterminada del repositorio", @@ -8236,14 +8231,7 @@ "f83c8937c4": "nombres de ramas", "5ecd91c5ef": "Prefijo agregado a los nombres de ramas al crear worktrees.", "68bd65fdb8": "Prefijo de rama", - "sourceControlGroupOrderTitle": "Orden de grupos de control de código fuente", - "sourceControlGroupOrderDescription": "Elige si Cambios, Cambios preparados o Archivos sin seguimiento aparecen primero en Control de código fuente.", - "groupOrder": "orden de grupos", - "changesFirst": "cambios primero", - "stagedFirst": "cambios preparados primero", - "untrackedFirst": "archivos sin seguimiento primero", "sourceControl": "control de código fuente", - "gitChanges": "cambios de git", "compareAgainstUpstreamTitle": "Base de comparación predeterminada", "compareAgainstUpstreamDescription": "Elige qué base usa Control de código fuente de forma predeterminada para las comparaciones de cambios confirmados. El upstream de la rama sigue automáticamente la rama actual y recurre a la rama predeterminada del repositorio cuando no hay upstream. Aun así, puedes cambiar la base de comparación por worktree desde el panel Git de ese worktree. Los objetivos de Pull Request y rebase no cambian.", "compareBase": "base de comparación", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 16a92c12c02..24c94d5c823 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -5988,11 +5988,6 @@ "3d172725cc": "なし", "1f32ba27a6": "カスタム", "a182c5125e": "Gitのユーザー名", - "sourceControlGroupOrderTitle": "ソース管理のグループ順序", - "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。", - "changesFirst": "変更を先頭", - "stagedFirst": "ステージ済みを先頭", - "untrackedFirst": "未追跡を先頭", "compareAgainstUpstreamTitle": "既定の比較ベース", "compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。", "compareBaseRepositoryDefault": "リポジトリ既定", @@ -8258,14 +8253,7 @@ "f83c8937c4": "ブランチの命名", "5ecd91c5ef": "ワークツリーの作成時にブランチ名に追加されるプレフィックス。", "68bd65fdb8": "ブランチプレフィックス", - "sourceControlGroupOrderTitle": "ソース管理のグループ順序", - "sourceControlGroupOrderDescription": "ソース管理で「変更」「ステージ済みの変更」「未追跡ファイル」のどれを先に表示するかを選択します。", - "groupOrder": "グループ順序", - "changesFirst": "変更を先頭", - "stagedFirst": "ステージ済みを先頭", - "untrackedFirst": "未追跡を先頭", "sourceControl": "ソース管理", - "gitChanges": "git の変更", "compareAgainstUpstreamTitle": "既定の比較ベース", "compareAgainstUpstreamDescription": "ソース管理がコミット済み変更の比較に既定で使うベースを選択します。ブランチの上流は現在のブランチに自動的に追従し、上流がない場合はリポジトリの既定ブランチにフォールバックします。比較ベースは各 worktree の Git パネルから worktree ごとに変更できます。Pull Request やリベースの対象は変更されません。", "compareBase": "比較基準", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0489f18507a..ead7ea624f4 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -5951,11 +5951,6 @@ "3d172725cc": "없음", "1f32ba27a6": "사용자 정의", "a182c5125e": "Git 사용자 이름", - "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", - "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", - "changesFirst": "변경 사항 먼저", - "stagedFirst": "스테이징된 항목 먼저", - "untrackedFirst": "추적되지 않는 파일 먼저", "compareAgainstUpstreamTitle": "기본 비교 기준", "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBaseRepositoryDefault": "저장소 기본값", @@ -8221,14 +8216,7 @@ "f83c8937c4": "브랜치 이름 지정", "5ecd91c5ef": "작업 트리를 생성할 때 브랜치 이름에 접두사가 추가됩니다.", "68bd65fdb8": "브랜치 접두사", - "sourceControlGroupOrderTitle": "소스 제어 그룹 순서", - "sourceControlGroupOrderDescription": "소스 제어에서 변경 사항, 스테이징된 변경 사항 또는 추적되지 않는 파일 중 무엇을 먼저 표시할지 선택합니다.", - "groupOrder": "그룹 순서", - "changesFirst": "변경 사항 먼저", - "stagedFirst": "스테이징된 항목 먼저", - "untrackedFirst": "추적되지 않는 파일 먼저", "sourceControl": "소스 제어", - "gitChanges": "git 변경 사항", "compareAgainstUpstreamTitle": "기본 비교 기준", "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBase": "비교 기준", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1b197b3eaac..b64faff2472 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -5963,11 +5963,6 @@ "3d172725cc": "没有任何", "1f32ba27a6": "自定义", "a182c5125e": "git用户名", - "sourceControlGroupOrderTitle": "源代码管理分组顺序", - "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。", - "changesFirst": "更改优先", - "stagedFirst": "已暂存优先", - "untrackedFirst": "未跟踪优先", "compareAgainstUpstreamTitle": "默认对比基准", "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBaseRepositoryDefault": "仓库默认", @@ -8233,14 +8228,7 @@ "f83c8937c4": "分支命名", "5ecd91c5ef": "创建工作树时添加到分支名称的前缀。", "68bd65fdb8": "分支前缀", - "sourceControlGroupOrderTitle": "源代码管理分组顺序", - "sourceControlGroupOrderDescription": "选择在源代码管理中优先显示“更改”、“已暂存更改”还是“未跟踪文件”。", - "groupOrder": "分组顺序", - "changesFirst": "更改优先", - "stagedFirst": "已暂存优先", - "untrackedFirst": "未跟踪优先", "sourceControl": "源代码管理", - "gitChanges": "git 更改", "compareAgainstUpstreamTitle": "默认对比基准", "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBase": "对比基准", diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index e8c55be052d..85780660b2a 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -23,9 +23,7 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').sourceControlViewMode).toBe('list') }) - it('keeps Source Control changes first by default', () => { - expect(getDefaultSettings('/tmp').sourceControlGroupOrder).toBe('changes-first') - }) + it('uses a commit-oriented Source Control layout by default', () => {}) it('defaults mobile pairing to discovered network addresses', () => { expect(getDefaultSettings('/tmp').mobilePairingCustomAddress).toBeNull() diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 80fca73c828..33b927dccf1 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -29,7 +29,6 @@ import { DEFAULT_LEFT_SIDEBAR_TINT_COLOR, DEFAULT_LEFT_SIDEBAR_TINT_OPACITY } from './left-sidebar-appearance' -import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' @@ -272,7 +271,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings { rightSidebarOpenByDefault: true, showGitIgnoredFiles: true, sourceControlViewMode: 'list', - sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, sourceControlCompareAgainstUpstream: false, showTitlebarAppName: true, showTasksButton: true, diff --git a/src/shared/source-control-group-order.test.ts b/src/shared/source-control-group-order.test.ts deleted file mode 100644 index 165d2359c3b..00000000000 --- a/src/shared/source-control-group-order.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - DEFAULT_SOURCE_CONTROL_GROUP_ORDER, - normalizeSourceControlGroupOrder -} from './source-control-group-order' - -describe('normalizeSourceControlGroupOrder', () => { - it('keeps supported source control group orders', () => { - expect(normalizeSourceControlGroupOrder('changes-first')).toBe('changes-first') - expect(normalizeSourceControlGroupOrder('staged-first')).toBe('staged-first') - expect(normalizeSourceControlGroupOrder('untracked-first')).toBe('untracked-first') - }) - - it('falls back to the default for malformed values', () => { - expect(normalizeSourceControlGroupOrder('tracked-first')).toBe( - DEFAULT_SOURCE_CONTROL_GROUP_ORDER - ) - expect(normalizeSourceControlGroupOrder(undefined)).toBe(DEFAULT_SOURCE_CONTROL_GROUP_ORDER) - }) -}) diff --git a/src/shared/source-control-group-order.ts b/src/shared/source-control-group-order.ts deleted file mode 100644 index acfe5160ac7..00000000000 --- a/src/shared/source-control-group-order.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SourceControlGroupOrder } from './types' - -export const DEFAULT_SOURCE_CONTROL_GROUP_ORDER: SourceControlGroupOrder = 'changes-first' - -export function normalizeSourceControlGroupOrder(value: unknown): SourceControlGroupOrder { - return value === 'changes-first' || value === 'staged-first' || value === 'untracked-first' - ? value - : DEFAULT_SOURCE_CONTROL_GROUP_ORDER -} diff --git a/src/shared/types.ts b/src/shared/types.ts index 947786da5bc..a3864c39061 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2699,7 +2699,6 @@ export type OpenInApplication = { } export type SourceControlViewMode = 'list' | 'tree' -export type SourceControlGroupOrder = 'changes-first' | 'staged-first' | 'untracked-first' export type LeftSidebarAppearanceMode = 'default' | 'match-terminal' | 'tinted' @@ -2885,8 +2884,6 @@ export type GlobalSettings = { showGitIgnoredFiles?: boolean /** Preferred Source Control changes layout. Per-user, not per-workspace. */ sourceControlViewMode: SourceControlViewMode - /** Preferred Source Control group order. Per-user, not per-workspace. */ - sourceControlGroupOrder: SourceControlGroupOrder /** Compare base defaults to the branch upstream instead of the repo default; affects only the compare/diff view, not the PR/rebase target. Per-user. */ sourceControlCompareAgainstUpstream: boolean /** Whether to show the Orca app name in the titlebar. */ diff --git a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts index 5cc07ef0e5b..151a4b02bbc 100644 --- a/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts +++ b/tests/e2e/setup-script-prompt-unreadable-orca-yaml.spec.ts @@ -65,8 +65,9 @@ async function installUnreadableOrcaYamlFault(electronApp: ElectronApplication): /** orca.yaml becomes readable again — every later check runs the production handler. */ async function healOrcaYamlRead(electronApp: ElectronApplication): Promise { await electronApp.evaluate(() => { - ;(globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean }) - .__orcaE2eOrcaYamlUnreadable = false + ;( + globalThis as typeof globalThis & { __orcaE2eOrcaYamlUnreadable?: boolean } + ).__orcaE2eOrcaYamlUnreadable = false }) } From 2ff2a1b26870c51ed59d2aa712dc96d6aa11ab27 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:52:49 -0700 Subject: [PATCH 14/46] Display SSH worktrees immediately using persisted metadata (#12646) * Display SSH worktrees immediately using persisted metadata Users can now see known worktrees for SSH hosts without waiting for the provider connection to establish. Worktrees are fetched from local metadata and displayed as non-authoritative, then merged without replacing richer live data once the provider becomes available. * Show SSH folder workspaces immediately via persisted metadata Add safeguards for metadata fallback: track authoritatively removed worktrees per host to prevent resurrection, position new rows within the host block to avoid jumping on authoritative scan arrival, and preserve co-owner detection status during merge. Coalesce concurrent metadata fetches to dedupe overlapping queries. --- src/main/ipc/worktrees.test.ts | 135 +++++- src/main/ipc/worktrees.ts | 84 +++- src/preload/api-types.ts | 5 + src/preload/index.ts | 3 + src/preload/ssh-authority-forwarding.test.ts | 10 + .../slices/degraded-repo-hydration.test.ts | 35 +- .../slices/degraded-repo-worktree-validity.ts | 12 +- .../src/store/slices/worktrees.test.ts | 425 ++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 223 +++++++++ .../detected-worktree-provider-contract.ts | 18 + 10 files changed, 944 insertions(+), 6 deletions(-) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index c83eece63b6..f5925e1acc1 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import type { CreateWorktreeResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract' -import { toSshExecutionHostId } from '../../shared/execution-host' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host' import * as localWorktreeFilesystem from '../local-worktree-filesystem' const ORIGINAL_PLATFORM = process.platform @@ -2589,6 +2589,139 @@ describe('registerWorktreeHandlers', () => { }) }) + it('lists every persisted SSH worktree without accessing the live provider', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const metaById = { + 'repo-1::/remote/repo': makeWorktreeMeta({ + displayName: 'main', + hostId: sshHostId + }), + 'repo-1::/remote/queued': makeWorktreeMeta({ + displayName: 'queued', + hostId: sshHostId + }), + 'repo-1::/remote/other-host': makeWorktreeMeta({ + displayName: 'other host', + hostId: toSshExecutionHostId('target-b') + }) + } + store.getRepos.mockReturnValue([sshRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toMatchObject({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: sshHostId, + result: { + repoId: sshRepo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [ + expect.objectContaining({ path: '/remote/repo', isMainWorktree: true }), + expect.objectContaining({ path: '/remote/queued', isMainWorktree: false }) + ] + } + }) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('lists SSH folder workspaces at the folder path, not the instance-suffixed id', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const folderRepo = { + id: 'repo-1', + path: '/remote/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a', + kind: 'folder' as const + } + const rootId = `${folderRepo.id}::${folderRepo.path}` + const instanceId = `${rootId}::workspace:11111111-2222-3333-4444-555555555555` + const metaById = { + [rootId]: makeWorktreeMeta({ displayName: 'root', hostId: sshHostId }), + [instanceId]: makeWorktreeMeta({ + displayName: 'second workspace', + hostId: sshHostId, + instanceId: '11111111-2222-3333-4444-555555555555' + }) + } + store.getRepos.mockReturnValue([folderRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = (await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: folderRepo.id, + executionHostId: sshHostId + })) as { status: string; result: { authoritative: boolean; worktrees: Worktree[] } } + + expect(result.status).toBe('complete') + expect(result.result.authoritative).toBe(false) + // Why: the git-worktree synthesizer would read the "::workspace:" tail as a directory. + expect(result.result.worktrees).toEqual([ + expect.objectContaining({ id: rootId, path: folderRepo.path, isMainWorktree: true }), + expect.objectContaining({ id: instanceId, path: folderRepo.path, isMainWorktree: false }) + ]) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + + it('rejects metadata-only reads for non-SSH execution hosts', async () => { + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: 'repo-1', + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + expect(store.getRepos).not.toHaveBeenCalled() + }) + + it('rejects ambiguous metadata-only SSH owners', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo-a', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo, { ...sshRepo, path: '/remote/repo-b' }]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: sshRepo.id, + executionHostId: sshHostId + }) + expect(store.getAllWorktreeMeta).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => { const sshRepo = { id: 'shared-repo', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c8dceec6bed..098e1e8191f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -49,7 +49,9 @@ import { import { PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, type DirectSshDetectedWorktreeRequest, + type HostQualifiedKnownWorktreeResult, type HostQualifiedDetectedWorktreeResult, + type ListKnownWorktreesForExecutionHostArgs, type ListDetectedWorktreesArgs, type ProviderRequestId } from '../../shared/detected-worktree-provider-contract' @@ -229,7 +231,8 @@ import { import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, - getRepoIdFromWorktreeId + getRepoIdFromWorktreeId, + getWorktreePathBasenameFromId } from '../../shared/worktree-id' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { @@ -804,6 +807,17 @@ function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorkt return index } +// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole +// store, so this is cheaper per call than the unfiltered index, not free. +function createSshWorktreeMetaIndexForRepo( + allMeta: Record, + repoId: string +): SshWorktreeMetaIndex { + return createSshWorktreeMetaIndex( + Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId) + ) +} + function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo { return { path, @@ -842,10 +856,15 @@ function listDisconnectedSshWorktrees( if (Object.keys(ownershipUpdates).length > 0) { store.setWorktreeMeta(candidate.id, ownershipUpdates) } + // Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename() + // applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must + // stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic + // basename instead. const worktree = mergeWorktree( repo.id, synthesizeSshGitWorktree(repo, candidate.path, meta), - meta + meta, + getWorktreePathBasenameFromId(candidate.id) ?? undefined ) byWorktreeId.delete(worktree.id) byWorktreeId.set(worktree.id, worktree) @@ -1826,6 +1845,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:listAll') ipcMain.removeHandler('worktrees:list') ipcMain.removeHandler('worktrees:listDetected') + ipcMain.removeHandler('worktrees:listKnownForExecutionHost') ipcMain.removeHandler('worktrees:cancelListDetected') ipcMain.removeHandler('worktrees:create') ipcMain.removeHandler('worktrees:prefetchCreateBase') @@ -1970,6 +1990,66 @@ export function registerWorktreeHandlers( } }) + ipcMain.handle( + 'worktrees:listKnownForExecutionHost', + (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { + // Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert — + // it owns no repo, so every guard below still rejects it. + const requestedRepoId = args?.repoId ?? '' + const requestedExecutionHostId = args?.executionHostId ?? 'ssh:' + const rejected = (): HostQualifiedKnownWorktreeResult => ({ + status: 'rejected', + repoId: requestedRepoId, + executionHostId: requestedExecutionHostId + }) + const parsedHost = parseExecutionHostId(requestedExecutionHostId) + if (parsedHost?.kind !== 'ssh') { + return rejected() + } + // Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the + // whole catalog, so a separate pass here is pure cost. + const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return rejected() + } + const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({ + status: 'complete', + repoId: repo.id, + executionHostId: requestedExecutionHostId, + result: { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees + } + }) + // Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does. + if (isFolderRepo(repo)) { + const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) => + isFolderWorkspaceIdForRepo(repo, worktreeId) + ) + return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds) + ? rejected() + : complete( + // Why: match the authoritative folder listing; without lineage these rows render flat and then + // reshuffle once the real scan lands. + projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) + ) + } + const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id) + return complete( + buildDisconnectedDetectedWorktrees( + store, + repo, + listDisconnectedSshWorktrees(store, repo, metaIndex) + ) + ) + } + ) + ipcMain.handle( 'worktrees:listDetected', async ( diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 094a93c3484..53bdb0e7349 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -30,8 +30,10 @@ import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { ReleaseChannel } from '../shared/release-channel' import type { + HostQualifiedKnownWorktreeResult, HostQualifiedDetectedWorktreeResult, LegacyDetectedWorktreeRequest, + ListKnownWorktreesForExecutionHostArgs, ListDetectedWorktreesArgs, ProviderRequestId } from '../shared/detected-worktree-provider-contract' @@ -1399,6 +1401,9 @@ export type PreloadApi = { ): Promise (args: LegacyDetectedWorktreeRequest): Promise } + listKnownForExecutionHost?: ( + args: ListKnownWorktreesForExecutionHostArgs + ) => Promise cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 8b3863dcf7c..cf602b3f1cc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -766,6 +766,9 @@ const api = { listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + listKnownForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), listAll: () => ipcRenderer.invoke('worktrees:listAll'), diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts index 7a81df602d2..04f13430801 100644 --- a/src/preload/ssh-authority-forwarding.test.ts +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -68,6 +68,16 @@ describe('native preload SSH authority forwarding', () => { >() }) + it('forwards host-qualified metadata-only worktree reads', async () => { + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + const args = { repoId: 'repo-1', executionHostId: 'ssh:ssh-1' as const } + + await api.worktrees.listKnownForExecutionHost?.(args) + + expect(invoke).toHaveBeenCalledWith('worktrees:listKnownForExecutionHost', args) + }) + it('forwards full-pair get and push states without cloning away authority', async () => { const state: SshConnectionState = { targetId: 'ssh-1', diff --git a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts index 93902bb9d49..48690c43f51 100644 --- a/src/renderer/src/store/slices/degraded-repo-hydration.test.ts +++ b/src/renderer/src/store/slices/degraded-repo-hydration.test.ts @@ -16,7 +16,7 @@ import { buildWorkspaceSessionPayload, shouldPersistWorkspaceSession } from '@/lib/workspace-session' -import { createTestStore, makeTab } from './store-test-helpers' +import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' const WORKTREE_ID = 'repo1::/path/degraded' const TERMINAL_ID = 'terminal-degraded' @@ -199,6 +199,39 @@ it('keeps a terminal-free degraded workspace selected through hydration and pers expect(restored.activeTabType).toBe('browser') }) +it('keeps chrome when a non-authoritative fallback lists only some of the repo worktrees', () => { + const store = createTestStore() + // Why: an SSH metadata fallback can be non-empty yet partial (host-less metas are skipped on multi-owner + // repos, agent-scratch stays hidden). A partial list must not read as proof the missing worktree was deleted. + const sibling = makeWorktree({ + id: 'repo1::/path/sibling', + repoId: 'repo1', + path: '/path/sibling' + }) + store.setState({ + repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { repo1: [sibling] }, + detectedWorktreesByRepo: { + repo1: { + repoId: 'repo1', + authoritative: false, + source: 'metadata-fallback', + worktrees: [] + } + } + }) + const session = makeDegradedRepoSession() + store.getState().hydrateWorkspaceSession(session) + store.getState().hydrateTabsSession(session) + store.getState().hydrateEditorSession(session) + store.getState().hydrateBrowserSession(session) + + const state = store.getState() + expect(state.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([TERMINAL_ID]) + expect(state.openFiles.map((file) => file.id)).toEqual([EDITOR_FILE_ID]) + expect(state.browserTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([BROWSER_ID]) +}) + it('drops terminal-free chrome when an authoritative scan proves deletion', () => { const store = createTestStore() hydrateWithRepoScan(store, makeTerminalFreeDegradedRepoSession(), true) diff --git a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts index 0fb4e42fd19..1a9693bdf5f 100644 --- a/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts +++ b/src/renderer/src/store/slices/degraded-repo-worktree-validity.ts @@ -45,13 +45,21 @@ export function buildValidWorktreeIdsForSessionHydration( .map((worktree) => worktree.id) ) const knownRepoIds = new Set(catalog.repos.map((repo) => repo.id)) + const detectedWorktreesByRepo = catalog.detectedWorktreesByRepo ?? {} const repoIdsWithLoadedWorktrees = new Set( Object.entries(worktreesByRepo) - .filter(([, worktrees]) => worktrees.length > 0) + // Why (#1158): a metadata fallback can be non-empty yet partial (host-less metas are skipped on + // multi-owner repos, agent-scratch stays hidden), so it cannot prove deletion the way a real listing can. + // Only an explicitly non-authoritative result is disqualified; a repo with no detection entry at all + // still counts as loaded, as before. + .filter( + ([repoId, worktrees]) => + worktrees.length > 0 && detectedWorktreesByRepo[repoId]?.authoritative !== false + ) .map(([repoId]) => repoId) ) const repoIdsWithAuthoritativeDetectedWorktrees = new Set( - Object.entries(catalog.detectedWorktreesByRepo ?? {}) + Object.entries(detectedWorktreesByRepo) .filter(([, detected]) => detected?.authoritative) .map(([repoId]) => repoId) ) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 6d8fff0696a..72d099a2731 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -21,6 +21,8 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { HostQualifiedDetectedWorktreeResult, + HostQualifiedKnownWorktreeResult, + ListKnownWorktreesForExecutionHostArgs, ListDetectedWorktreesArgs } from '../../../../shared/detected-worktree-provider-contract' import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' @@ -105,12 +107,17 @@ const listDetectedMock = vi.fn< return qualifyDetectedResult(args, result) }) +const listKnownForExecutionHostMock = vi.fn< + (args: ListKnownWorktreesForExecutionHostArgs) => Promise +>(async (args) => ({ status: 'rejected', ...args })) + const mockApi = { worktrees: { create: vi.fn(), prefetchCreateBase: vi.fn().mockResolvedValue(undefined), list: worktreeListMock, listDetected: listDetectedMock, + listKnownForExecutionHost: listKnownForExecutionHostMock, cancelListDetected: vi.fn().mockResolvedValue(undefined), listLineage: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), @@ -153,6 +160,7 @@ import { createWorktreeSlice, getHostedReviewLinkMutationGenerationForTests, getHostedReviewLinkWorktreeAliasCountForTests, + resetAuthoritativelyRemovedWorktreeMemoryForTests, resetHostedReviewLinkMutationGenerationForTests } from './worktrees' import type { PendingWorktreeCreation } from '@/lib/pending-worktree-creation' @@ -439,6 +447,7 @@ describe('fetchWorktrees', () => { vi.clearAllMocks() resetRemoteRuntimeMocks() clearHugeRepoWarningDismissalsForTests() + resetAuthoritativelyRemovedWorktreeMemoryForTests() }) it('does not notify subscribers when the fetched payload is unchanged', async () => { @@ -1350,6 +1359,365 @@ describe('fetchWorktrees', () => { expect(store.getState().detectedWorktreesByRepo).toBe(detectedWorktreesByRepo) }) + it('shows persisted secondary worktrees while SSH is connecting', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued', + displayName: 'queued' + }) + const detected = makeDetectedResult('repo-ssh', [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: detected + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connecting', + error: null, + reconnectAttempt: 0, + providerEpoch: null + } + ] + ]) + } as Partial) + + await expect(store.getState().fetchWorktrees(sshRepo.id)).resolves.toBe(false) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...queued, hostId: 'ssh:ssh-1' } + ]) + expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' + }) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + }) + + it('adds metadata rows without replacing richer cached SSH worktrees', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const existing = makeWorktree({ + id: 'repo-ssh::/home/orca/existing', + repoId: 'repo-ssh', + path: '/home/orca/existing', + hostId: 'ssh:ssh-1', + head: 'live-head', + branch: 'refs/heads/live-branch' + }) + const metadataExisting = { ...existing, head: '', branch: '' } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult('repo-ssh', [metadataExisting, queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map(), + worktreesByRepo: { [sshRepo.id]: [existing] } + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + existing, + { ...queued, hostId: 'ssh:ssh-1' } + ]) + }) + + it('inserts metadata rows inside the SSH block instead of past sibling hosts', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-shared', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined } + const sshExisting = makeWorktree({ + id: 'repo-shared::/home/orca/existing', + repoId: sshRepo.id, + path: '/home/orca/existing', + hostId: 'ssh:ssh-1' + }) + const localExisting = makeWorktree({ + id: 'repo-shared::/local/existing', + repoId: sshRepo.id, + path: '/local/existing', + hostId: LOCAL_EXECUTION_HOST_ID + }) + const queued = makeWorktree({ + id: 'repo-shared::/home/orca/queued', + repoId: sshRepo.id, + path: '/home/orca/queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo, localRepo], + sshConnectionStates: new Map(), + worktreesByRepo: { [sshRepo.id]: [sshExisting, localExisting] } + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id, { executionHostId: 'ssh:ssh-1' }) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + sshExisting, + { ...queued, hostId: 'ssh:ssh-1' }, + localExisting + ]) + }) + + it('drops metadata rows when SSH authority lands during the read', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const live = makeWorktree({ + id: 'repo-ssh::/home/orca/live', + repoId: 'repo-ssh', + path: '/home/orca/live', + hostId: 'ssh:ssh-1' + }) + // Why: deleted on the host, so an authoritative scan already purged it; the late metadata write must not resurrect it. + const purged = makeWorktree({ + id: 'repo-ssh::/home/orca/purged', + repoId: 'repo-ssh', + path: '/home/orca/purged' + }) + listKnownForExecutionHostMock.mockImplementationOnce(async (args) => { + store.setState({ + sshConnectionStates: new Map([ + [ + TEST_SSH_AUTHORITY.targetId, + { + targetId: TEST_SSH_AUTHORITY.targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: TEST_SSH_AUTHORITY.providerEpoch, + connectionGeneration: TEST_SSH_AUTHORITY.connectionGeneration + } + ] + ]), + worktreesByRepo: { [sshRepo.id]: [live] } + } as Partial) + return { + status: 'complete', + repoId: args.repoId, + executionHostId: args.executionHostId, + result: makeDetectedResult(args.repoId, [live, purged], { + authoritative: false, + source: 'metadata-fallback' + }) + } + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map() + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + }) + + it('replaces metadata rows once the authoritative SSH scan lands', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const live = makeWorktree({ + id: 'repo-ssh::/home/orca/live', + repoId: 'repo-ssh', + path: '/home/orca/live', + hostId: 'ssh:ssh-1' + }) + const stale = makeWorktree({ + id: 'repo-ssh::/home/orca/stale', + repoId: 'repo-ssh', + path: '/home/orca/stale' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [stale], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + const connectedStates = createTestStore().getState().sshConnectionStates + store.setState({ repos: [sshRepo], sshConnectionStates: new Map() } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...stale, hostId: 'ssh:ssh-1' } + ]) + + worktreeListMock.mockResolvedValueOnce([live]) + store.setState({ sshConnectionStates: connectedStates } as Partial) + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + }) + + it('keeps the repo detection entry authoritative while appending metadata rows', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-shared', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const localRepo = { ...sshRepo, path: '/local/repo', connectionId: undefined } + const scanned = makeWorktree({ + id: 'repo-shared::/local/scanned', + repoId: sshRepo.id, + path: '/local/scanned' + }) + const fromMetadata = makeWorktree({ + id: 'repo-shared::/home/orca/queued', + repoId: sshRepo.id, + path: '/home/orca/queued' + }) + const authoritative = makeDetectedResult(sshRepo.id, [scanned]) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [fromMetadata], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo, localRepo], + sshConnectionStates: new Map(), + detectedWorktreesByRepo: { [sshRepo.id]: authoritative } + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id, { executionHostId: 'ssh:ssh-1' }) + + const detected = store.getState().detectedWorktreesByRepo[sshRepo.id] + // Why: this entry is shared with the co-owning local host, so the fallback must not demote its scan. + expect(detected?.authoritative).toBe(true) + expect(detected?.source).toBe('git') + expect(detected?.worktrees.map((worktree) => worktree.id)).toEqual([ + scanned.id, + fromMetadata.id + ]) + }) + + it('does not resurrect worktrees an authoritative SSH scan already removed', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const live = makeWorktree({ + id: 'repo-ssh::/home/orca/live', + repoId: 'repo-ssh', + path: '/home/orca/live', + hostId: 'ssh:ssh-1' + }) + const deletedOnRemote = makeWorktree({ + id: 'repo-ssh::/home/orca/deleted', + repoId: 'repo-ssh', + path: '/home/orca/deleted' + }) + const metadataResult = () => ({ + status: 'complete' as const, + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' as const, + result: makeDetectedResult(sshRepo.id, [deletedOnRemote], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + const connectedStates = createTestStore().getState().sshConnectionStates + listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult()) + store.setState({ repos: [sshRepo], sshConnectionStates: new Map() } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...deletedOnRemote, hostId: 'ssh:ssh-1' } + ]) + + // The host connects and an authoritative scan proves the worktree is gone. + worktreeListMock.mockResolvedValueOnce([live]) + store.setState({ sshConnectionStates: connectedStates } as Partial) + await store.getState().fetchWorktrees(sshRepo.id) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + + // The host drops again; persisted metadata still lists the deleted worktree. + listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult()) + store.setState({ sshConnectionStates: new Map() } as Partial) + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + }) + it('keeps worktree maps byte-identical for stale and malformed direct results', async () => { const store = createTestStore() const existing = makeWorktree({ @@ -7543,6 +7911,63 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { addedAt: 0 } + it.each([false, true])( + 'hydrates connecting SSH worktrees with hydration purge completed=%s', + async (hasHydratedWorktreePurge) => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued', + displayName: 'queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo], + hasHydratedWorktreePurge, + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connecting', + error: null, + reconnectAttempt: 0, + providerEpoch: null + } + ] + ]) + } as Partial) + + await store.getState().fetchAllWorktrees() + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...queued, hostId: 'ssh:ssh-1' } + ]) + expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' + }) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + } + ) + it('preserves resolved inline legacy lineage when side-map hydration is absent', async () => { const store = createTestStore() const parent = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 8facfa55dc2..3859cec22fc 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -121,6 +121,7 @@ import { import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' import type { HostQualifiedDetectedWorktreeResult, + HostQualifiedKnownWorktreeResult, ListDetectedWorktreesArgs, ProviderRequestId, SshExecutionHostId @@ -2877,6 +2878,8 @@ function mergeFetchedWorktrees( args: FencedWorktreeMergeArgs ): boolean { let admitted = false + let authoritativelyRemovedIds: readonly string[] = [] + let authoritativelySeenIds: readonly string[] = [] set((s) => { if ( !isCurrentDetectedWorktreeRefresh(s, args.refresh) || @@ -2950,6 +2953,10 @@ function mergeFetchedWorktrees( args.refresh.result, args.hostId ) + authoritativelyRemovedIds = removedIds + if (args.refresh.result.authoritative) { + authoritativelySeenIds = args.refresh.result.worktrees.map((worktree) => worktree.id) + } const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[args.repoId], mergedWorktrees) const detectedChanged = !areDetectedWorktreeResultsEqual( s.detectedWorktreesByRepo[args.repoId], @@ -2979,9 +2986,210 @@ function mergeFetchedWorktrees( ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) } }) + if (admitted) { + // Why: applied outside the updater so a repeated updater call cannot double-apply the removal memory. + forgetAuthoritativelyRemovedWorktrees(args.hostId, authoritativelySeenIds) + rememberAuthoritativelyRemovedWorktrees(args.hostId, authoritativelyRemovedIds) + } return admitted } +// Why: an authoritative scan is the only proof a remote worktree is gone, but SSH WorktreeMeta is exempt from +// gcStaleWorktreeMeta (persistence.ts:407,415), so without this memory the metadata fallback re-appends every +// deleted row on the next disconnect — forever. +const AUTHORITATIVE_REMOVAL_MEMORY_LIMIT = 512 +const authoritativelyRemovedWorktreeIdsByHost = new Map>() + +function rememberAuthoritativelyRemovedWorktrees( + hostId: ExecutionHostId, + worktreeIds: readonly string[] +): void { + if (worktreeIds.length === 0) { + return + } + const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) ?? new Set() + for (const worktreeId of worktreeIds) { + // Why: re-insert so a re-removed id moves to the back of the insertion order the cap evicts from. + removed.delete(worktreeId) + removed.add(worktreeId) + } + for (const oldest of removed) { + if (removed.size <= AUTHORITATIVE_REMOVAL_MEMORY_LIMIT) { + break + } + removed.delete(oldest) + } + authoritativelyRemovedWorktreeIdsByHost.set(hostId, removed) +} + +// Why: a remote path can be recreated, so a scan that reports it again retracts the deletion verdict. +function forgetAuthoritativelyRemovedWorktrees( + hostId: ExecutionHostId, + worktreeIds: Iterable +): void { + const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) + if (!removed) { + return + } + for (const worktreeId of worktreeIds) { + removed.delete(worktreeId) + } + if (removed.size === 0) { + authoritativelyRemovedWorktreeIdsByHost.delete(hostId) + } +} + +/** Test-only: module-level removal memory would otherwise leak across cases in one file. */ +export function resetAuthoritativelyRemovedWorktreeMemoryForTests(): void { + authoritativelyRemovedWorktreeIdsByHost.clear() +} + +function appendMissingWorktreesForHost< + T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } +>( + current: readonly T[] | undefined, + incoming: readonly T[], + hostId: ExecutionHostId, + options: WorktreeHostMatchOptions +): T[] { + const existing = current ?? [] + const existingHostIds = new Set( + existing + .filter((worktree) => worktreeMatchesHost(worktree, hostId, options)) + .map(({ id }) => id) + ) + const missing = incoming.filter((worktree) => !existingHostIds.has(worktree.id)) + if (missing.length === 0) { + return [...existing] + } + // Why: land inside the host's block like mergeWorktreesForHost does, else these rows sit past sibling hosts and visibly jump once the authoritative scan splices them back. + const lastHostIndex = existing.findLastIndex((worktree) => + worktreeMatchesHost(worktree, hostId, options) + ) + if (lastHostIndex === -1) { + return [...existing, ...missing] + } + return [...existing.slice(0, lastHostIndex + 1), ...missing, ...existing.slice(lastHostIndex + 1)] +} + +function isAdmittedKnownSshWorktreeResult( + result: HostQualifiedKnownWorktreeResult, + repoId: string, + executionHostId: SshExecutionHostId +): result is Extract { + return ( + result.status === 'complete' && + result.repoId === repoId && + result.executionHostId === executionHostId && + isDetectedWorktreeListResult(result.result) && + result.result.repoId === repoId && + result.result.authoritative === false + ) +} + +const inflightKnownSshWorktreeFetches = new Map< + string, + Promise +>() + +// Why: the authoritative path dedupes through listDetectedWorktreesForRepoCoalesced; without a matching guard +// the four refresh triggers can each issue this IPC and its merge for the same repo/host. +async function fetchKnownSshWorktreesForRepo( + set: Parameters>[0], + repoId: string, + executionHostId: SshExecutionHostId +): Promise { + const coalesceKey = `${repoId}${executionHostId}` + const inflight = inflightKnownSshWorktreeFetches.get(coalesceKey) + if (inflight) { + return await inflight + } + const request = runKnownSshWorktreeFetch(set, repoId, executionHostId).finally(() => { + inflightKnownSshWorktreeFetches.delete(coalesceKey) + }) + inflightKnownSshWorktreeFetches.set(coalesceKey, request) + return await request +} + +async function runKnownSshWorktreeFetch( + set: Parameters>[0], + repoId: string, + executionHostId: SshExecutionHostId +): Promise { + // Why: reads the local store only, so a runtime-hub session (whose repo ids live on the hub) always gets 'rejected' and keeps the pre-existing no-op. + const listKnown = window.api.worktrees.listKnownForExecutionHost + if (typeof listKnown !== 'function') { + return null + } + const result = await listKnown({ repoId, executionHostId }) + if (!isAdmittedKnownSshWorktreeResult(result, repoId, executionHostId)) { + return null + } + // Why: persisted SSH metadata outlives the remote worktree, so drop rows a completed scan already proved gone. + const suppressedIds = authoritativelyRemovedWorktreeIdsByHost.get(executionHostId) + const known = + suppressedIds && suppressedIds.size > 0 + ? { + ...result.result, + worktrees: result.result.worktrees.filter((worktree) => !suppressedIds.has(worktree.id)) + } + : result.result + let admitted = false + set((state) => { + // Why: the provider can connect during the await; authoritative rows already replaced this host, so appending stale metadata would resurrect purged worktrees. + if ( + getCurrentDirectSshAuthority(state, executionHostId) || + !repoHasExactlyOneExecutionHostOwner(state, repoId, executionHostId, false) + ) { + return state + } + admitted = true + const setup = getProjectHostSetupForRepoHost(state, repoId, executionHostId) + const matchOptions = worktreeHostMatchOptions(state, repoId, executionHostId) + const incomingDetected = known.worktrees.map((worktree) => + withRepoHostOwnership(worktree, executionHostId, setup) + ) + const priorDetected = state.detectedWorktreesByRepo[repoId] + // Why: only the rows are ours to merge. This entry is keyed by repo alone, so adopting the fallback's + // authoritative/source would demote a sibling host's completed scan and blank every authoritative-gated surface. + const detected = { + ...(priorDetected ?? known), + worktrees: appendMissingWorktreesForHost( + priorDetected?.worktrees, + incomingDetected, + executionHostId, + matchOptions + ) + } + const worktrees = appendMissingWorktreesForHost( + state.worktreesByRepo[repoId], + toVisibleWorktrees(known, executionHostId, setup), + executionHostId, + matchOptions + ) + const worktreesChanged = !areWorktreesEqual(state.worktreesByRepo[repoId], worktrees) + const detectedChanged = !areDetectedWorktreeResultsEqual( + state.detectedWorktreesByRepo[repoId], + detected + ) + if (!worktreesChanged && !detectedChanged) { + return state + } + return { + ...(worktreesChanged + ? { + worktreesByRepo: { ...state.worktreesByRepo, [repoId]: worktrees }, + sortEpoch: state.sortEpoch + 1 + } + : {}), + ...(detectedChanged + ? { detectedWorktreesByRepo: { ...state.detectedWorktreesByRepo, [repoId]: detected } } + : {}) + } + }) + return admitted ? known : null +} + export type DirectSshDetectedWorktreeRefresh = { waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId'] providerRequestId: ProviderRequestId @@ -3112,6 +3320,9 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + // Why: this function's contract is detected-only. The fallback runs for its store side effect, but + // callers keep seeing null as they did before the metadata path existed. + await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) return null } const refresh = await listDetectedWorktreesForRepoCoalesced( @@ -3210,6 +3421,11 @@ export const createWorktreeSlice: StateCreator ? (directCallerAuthority ?? getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + // Why: requireAuthoritative callers asked for authoritative-or-nothing, so writing non-authoritative + // rows as a side effect before returning false would silently weaken that contract. + if (!options?.requireAuthoritative) { + await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) + } return false } const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { @@ -3274,6 +3490,7 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return } const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { @@ -3325,6 +3542,7 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return { repoId: r.id, ok: false as const } } const refresh = await listDetectedWorktreesForRepoCoalesced( @@ -4082,6 +4300,11 @@ export const createWorktreeSlice: StateCreator // Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement. forgetHugeRepoWarningDismissalsForWorktrees([worktreeId]) + // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an + // in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it. + if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') { + rememberAuthoritativelyRemovedWorktrees(hostId, [worktreeId]) + } const worktreeDisplayName = worktreeBeforeRemoval?.displayName?.trim() if (worktreeDisplayName) { diff --git a/src/shared/detected-worktree-provider-contract.ts b/src/shared/detected-worktree-provider-contract.ts index 4c5289db8fa..38b38ace37e 100644 --- a/src/shared/detected-worktree-provider-contract.ts +++ b/src/shared/detected-worktree-provider-contract.ts @@ -23,6 +23,24 @@ export type ListDetectedWorktreesArgs = | LocalDetectedWorktreeRequest | DirectSshDetectedWorktreeRequest +export type ListKnownWorktreesForExecutionHostArgs = { + repoId: string + executionHostId: SshExecutionHostId +} + +export type HostQualifiedKnownWorktreeResult = + | { + status: 'complete' + repoId: string + executionHostId: SshExecutionHostId + result: DetectedWorktreeListResult + } + | { + status: 'rejected' + repoId: string + executionHostId: SshExecutionHostId + } + export type AuthoritativeDetectedWorktreeHost = | { kind: 'local' From 4774d38239597a2ddd027e2e900afdd14699f300 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:58:55 -0700 Subject: [PATCH 15/46] fix(sidebar): keep PR status visible with activity (#12793) --- .../sidebar/WorktreeCard.pr-display.test.tsx | 23 +-- .../src/components/sidebar/WorktreeCard.tsx | 15 +- .../sidebar/WorktreeCardReviewStatus.test.tsx | 53 ++++++ .../sidebar/WorktreeCardReviewStatus.tsx | 52 ++++++ .../sidebar/WorktreeCardStatusSlot.test.tsx | 164 +----------------- .../sidebar/WorktreeCardStatusSlot.tsx | 131 ++++---------- 6 files changed, 165 insertions(+), 273 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index a219841b949..d1e95db8c74 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -24,6 +24,7 @@ let issueCache: Record = {} let prCache: Record = {} let workspacePortScan: WorkspacePortScanResult | null = null let settings: Partial | null = null +let activityStatus = 'inactive' vi.mock('@/store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => @@ -60,7 +61,7 @@ vi.mock('@/components/ui/tooltip', () => ({ })) vi.mock('./use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: () => 'inactive' + useWorktreeActivityStatus: () => activityStatus })) vi.mock('./CacheTimer', () => ({ @@ -162,6 +163,7 @@ describe('WorktreeCard linked PR display', () => { prCache = {} workspacePortScan = null settings = null + activityStatus = 'inactive' }) it('keeps linked GH PR status out of the left status slot by default', async () => { @@ -229,11 +231,10 @@ describe('WorktreeCard linked PR display', () => { ) expect(unreadMarkup).not.toContain('aria-label="Mark as read"') - expect(unreadMarkup).toContain('PR checks: Failed · Unread') + expect(unreadMarkup).toContain('Inactive · Unread') + expect(unreadMarkup).toContain('PR checks: Failed') expect(unreadMarkup).not.toContain('Mark read') - expect(unreadMarkup).toContain('size-[13px] translate-x-px') - expect(unreadMarkup).not.toContain('lucide-bell') - expect(unreadMarkup).not.toContain('text-amber-500') + expect(unreadMarkup).toContain('size-[13px]') expect(unreadMarkup).toContain('data-worktree-status-lane-unread=""') expect(unreadMarkup).toContain('data-worktree-unread-alert=""') expect(unreadMarkup).toContain('bg-amber-500') @@ -241,7 +242,7 @@ describe('WorktreeCard linked PR display', () => { expect(getInlineRenameTitleTag(readMarkup)).toContain('font-normal text-foreground/80') }, 20_000) - it('shows linked GH PR status in the left status slot before hosted review details are cached when new card style is on', async () => { + it('shows linked GH PR status in the title row before hosted review details are cached when new card style is on', async () => { settings = { experimentalNewWorktreeCardStyle: true } const { default: WorktreeCard } = await import('./WorktreeCard') @@ -629,9 +630,10 @@ describe('WorktreeCard linked PR display', () => { expect(markup).not.toContain('58941') }) - it('renders linked PR status in the left status slot instead of the right metadata list', async () => { + it('renders activity and linked PR status in independent sidebar lanes', async () => { settings = { experimentalNewWorktreeCardStyle: true } worktreeCardProperties = ['status'] + activityStatus = 'active' hostedReviewCache = { 'local::repo-1::feature/local-branch': { data: makeHostedReview({ status: 'failure' }), @@ -645,12 +647,13 @@ describe('WorktreeCard linked PR display', () => { ) expect(markup).toContain('PR checks: Failed') - expect(markup).toContain('text-rose-500/85') + expect(markup).toContain('Active') + expect(markup).toContain('bg-emerald-500') + expect(markup).toContain('data-worktree-card-review-status=""') expect(markup).not.toContain('Linked PR #456') - expect(markup).not.toContain('CI checks') }) - it('uses branch PR cache for the status slot before hosted-review metadata warms', async () => { + it('uses branch PR cache for the title-row status before hosted-review metadata warms', async () => { settings = { experimentalNewWorktreeCardStyle: true } worktreeCardProperties = ['status'] prCache = { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index b64ffd91cc5..26a2d020235 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -26,6 +26,7 @@ import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt' import WorktreeCardAgents from './WorktreeCardAgents' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' +import { WorktreeCardReviewStatus } from './WorktreeCardReviewStatus' import { cn } from '@/lib/utils' import { WorktreeCardSshHostControl } from './WorktreeCardSshHostControl' import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' @@ -1207,11 +1208,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ !!conflictOperation && conflictOperation !== 'unknown' && conflictOperation !== 'rebase' const hasMetadataBadge = showConflictOperationBadge const showUnreadQuickAction = !affiliateListMode && showStatus && !newCardStyle - // Why: the slot owns the unread/status lane; legacy keeps the bell toggle, the new card keeps the glyph passive. + // Why: the slot owns activity and unread; review/check state has its own title-row indicator. const showCombinedStatusSlot = showStatus + const showReviewStatus = newCardStyle && showStatus && statusLaneReview !== null const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts) - const showTitleRowIndicators = (newCardStyle || compactCards) && (hasDetails || hasPorts) + const showTitleRowIndicators = + (newCardStyle || compactCards) && (hasDetails || hasPorts || showReviewStatus) // Why: grouped views can hide the repo badge; don't reserve a blank metadata lane unless there's real content. const hasDetailedMetaRowContent = Boolean( (showRepoBadgeInMetaRow && repo) || @@ -1370,7 +1373,12 @@ const WorktreeCard = React.memo(function WorktreeCard({ detailsAndPortsContent ) const titleRowIndicators = showTitleRowIndicators ? ( -
{detailsAndPorts}
+
+ {showReviewStatus && statusLaneReview ? ( + + ) : null} + {detailsAndPorts} +
) : null const hasSecondaryCardContent = hasMetaRow || !!remoteBranchConflict || showInlineAgentList || showLineageChildChip @@ -1404,7 +1412,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ unreadTooltip={unreadTooltip} onPointerDown={stopQuickActionPointerPropagation} onToggleUnread={handleToggleUnreadQuick} - prDisplay={statusLaneReview} newCardStyle={newCardStyle} hasBranchIdentity={hasPathIdentityEnabled && Boolean(branchIdentityDisplay)} /> diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx new file mode 100644 index 00000000000..dab06044df9 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.test.tsx @@ -0,0 +1,53 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { WorktreeCardReviewStatus } from './WorktreeCardReviewStatus' +import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +type ReviewOverrides = { + provider?: Exclude + state?: WorktreeCardPrDisplay['state'] + status?: WorktreeCardPrDisplay['status'] +} + +function renderReview(overrides: ReviewOverrides = {}): string { + const review: WorktreeCardPrDisplay = { + provider: 'github', + number: 123, + title: 'Review me', + state: 'open', + ...overrides + } + return renderToStaticMarkup() +} + +describe('WorktreeCardReviewStatus', () => { + it.each([ + [{ state: 'merged' }, 'PR: Merged'], + [{ state: 'closed' }, 'PR: Closed'], + [{ state: 'draft' }, 'PR: Draft'], + [{ status: 'failure' }, 'PR checks: Failed'], + [{ status: 'pending' }, 'PR checks: Pending'], + [{ status: 'success' }, 'PR checks: Passing'], + [{}, 'PR: Open'] + ] as const)('describes review state %o as %s', (overrides, label) => { + expect(renderReview(overrides)).toContain(label) + }) + + it('uses the compact generic review glyph in its own lane', () => { + const markup = renderReview({ provider: 'gitlab', status: 'pending' }) + + expect(markup).toContain('data-worktree-card-review-status=""') + expect(markup).toContain('MR checks: Pending') + expect(markup).toContain('viewBox="0 0 16 16"') + expect(markup).toContain('size-[13px]') + expect(markup).toContain('text-amber-500/85') + expect(markup).not.toContain('lucide-git-merge') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx new file mode 100644 index 00000000000..cab5ba030c1 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardReviewStatus.tsx @@ -0,0 +1,52 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' +import { getReviewLabel, ReviewIcon } from './worktree-review-helpers' + +type WorktreeCardReviewStatusProps = { + review: WorktreeCardPrDisplay +} + +function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string { + const label = getReviewLabel(review) + if (review.state === 'merged') { + return `${label}: Merged` + } + if (review.state === 'closed') { + return `${label}: Closed` + } + if (review.state === 'draft') { + return `${label}: Draft` + } + if (review.status === 'failure') { + return `${label} checks: Failed` + } + if (review.status === 'pending') { + return `${label} checks: Pending` + } + if (review.status === 'success') { + return `${label} checks: Passing` + } + return `${label}: Open` +} + +export function WorktreeCardReviewStatus({ + review +}: WorktreeCardReviewStatusProps): React.JSX.Element { + const tooltip = getReviewStatusTooltip(review) + return ( + + + + + {tooltip} + + + + {tooltip} + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index 8f204527c9a..bd1971bb614 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -2,7 +2,6 @@ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' -import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' const mocks = vi.hoisted(() => ({ status: 'active' @@ -23,21 +22,6 @@ describe('WorktreeCardStatusSlot', () => { mocks.status = 'active' }) - const review: WorktreeCardPrDisplay = { - provider: 'github', - number: 123, - title: 'Review me', - state: 'open', - status: 'failure' - } - const gitlabReview: WorktreeCardPrDisplay = { - provider: 'gitlab', - number: 456, - title: 'Review me', - state: 'open', - status: 'pending' - } - it('lets the unread bell replace the visual status dot by default', () => { const markup = renderToStaticMarkup( { expect(markup).toContain('bg-emerald-500') }) - it('keeps the quiet active dot ahead of PR status by default', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('keeps the active dot ahead of PR status when new card style is on', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Active') - expect(markup).toContain('inline-flex size-5 items-center justify-center') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - it('keeps the emerald activity dot ahead of branch identity when new card style is on', () => { const markup = renderToStaticMarkup( { expect(markup).not.toContain('lucide-git-branch') }) - it('uses the unified compact review glyph for GitLab MR status', () => { - mocks.status = 'inactive' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('MR checks: Pending') - expect(markup).toContain('viewBox="0 0 16 16"') - expect(markup).toContain('size-[13px] translate-x-px') - expect(markup).toContain('text-amber-500/85') - expect(markup).not.toContain('lucide-git-merge') - }) - - it('keeps the done dot ahead of PR status when new card style is on', () => { + it('shows done activity in new card style', () => { mocks.status = 'done' const markup = renderToStaticMarkup( { unreadTooltip="Mark as unread" onPointerDown={vi.fn()} onToggleUnread={vi.fn()} - prDisplay={review} newCardStyle /> ) expect(markup).toContain('Done') expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('uses PR status instead of the inactive dot when new card style is on', () => { - mocks.status = 'inactive' - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('PR checks: Failed') - expect(markup).toContain('text-rose-500/85') - expect(markup).not.toContain('bg-neutral-500/40') }) it('uses a branch icon with branch-only tooltip copy on quiet rows', () => { @@ -363,7 +261,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('lucide-git-branch') }) - it('keeps working activity ahead of PR status in new card style', () => { + it('shows working activity in new card style', () => { mocks.status = 'working' const markup = renderToStaticMarkup( { unreadTooltip="Mark as unread" onPointerDown={vi.fn()} onToggleUnread={vi.fn()} - prDisplay={review} newCardStyle /> ) @@ -382,10 +279,9 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('Working') expect(markup).toContain('inline-flex size-5 items-center justify-center') expect(markup).toContain('border-yellow-500') - expect(markup).not.toContain('PR checks: Failed') }) - it('keeps permission activity ahead of PR status in new card style', () => { + it('shows permission activity in new card style', () => { mocks.status = 'permission' const markup = renderToStaticMarkup( { unreadTooltip="Mark as unread" onPointerDown={vi.fn()} onToggleUnread={vi.fn()} - prDisplay={review} newCardStyle /> ) @@ -404,59 +299,6 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('Needs permission') expect(markup).toContain('lucide-message-circle-question-mark') expect(markup).toContain('text-amber-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('keeps unread ahead of PR status by default', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('aria-label="Mark as read"') - expect(markup).toContain('Mark as read') - expect(markup).not.toContain('Active · Mark as read') - expect(markup).not.toContain('PR checks: Failed') - expect(markup).not.toContain('bg-emerald-500') - expect(markup).toContain('text-amber-500') - }) - - it('overlays an unread badge on PR status when new card style is on', () => { - mocks.status = 'inactive' - const markup = renderToStaticMarkup( - - ) - - expect(markup).not.toContain('aria-label="Mark as read"') - expect(markup).not.toContain('Mark as read') - expect(markup).toContain('PR checks: Failed · Unread') - expect(markup).toContain('data-worktree-status-lane-unread=""') - expect(markup).toContain('data-worktree-unread-alert=""') - expect(markup).not.toContain('group/unread') - expect(markup).not.toContain('cursor-pointer') - expect(markup).toContain('text-rose-500/85') - expect(markup).toContain('bg-amber-500') - expect(markup).not.toContain('lucide-bell') - expect(markup).not.toContain('text-amber-500') - expect(markup).not.toContain('bg-emerald-500') }) it('overlays an unread badge on the branch icon in new card style', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx index 527236c5549..d1a96bcdfea 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx @@ -3,12 +3,10 @@ import { Bell, GitBranch } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' -import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status' +import { getWorktreeStatusLabel } from '@/lib/worktree-status' import { FilledBellIcon } from './WorktreeCardHelpers' import StatusIndicator from './StatusIndicator' import { useWorktreeActivityStatus } from './use-worktree-activity-status' -import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' -import { getReviewLabel, ReviewIcon } from './worktree-review-helpers' type WorktreeCardStatusSlotProps = { worktreeId: string @@ -18,24 +16,20 @@ type WorktreeCardStatusSlotProps = { unreadTooltip: string onToggleUnread: React.MouseEventHandler onPointerDown: React.PointerEventHandler - prDisplay?: WorktreeCardPrDisplay | null newCardStyle?: boolean hasBranchIdentity?: boolean branchIdentityLabel?: string className?: string } -// Passive identity only replaces status when the workspace is inactive. -const IDENTITY_REPLACEABLE_STATUSES = new Set(['inactive']) -// Why: a missing review display can also mean provider state is unavailable, -// so the passive label names the identity cue without claiming no review exists. +// Why: the passive label names the identity cue without claiming review state. function getDefaultBranchIdentityLabel(): string { return translate('auto.components.sidebar.WorktreeCardStatusSlot.branchIdentity', 'Branch') } // Why: branch-style SVGs are optically left-heavy; this keeps them aligned with // the centered activity dots in the shared status column. -const compactReviewAndBranchStatusIconClassName = 'size-[13px] translate-x-px' -const branchStatusIconClassName = `${compactReviewAndBranchStatusIconClassName} text-muted-foreground/70` +const compactBranchStatusIconClassName = 'size-[13px] translate-x-px' +const branchStatusIconClassName = `${compactBranchStatusIconClassName} text-muted-foreground/70` // Why: a left-edge badge overlays unread on the status glyph without widening // the lane or indenting the title; ring-sidebar cuts the dot out from busy icons. const newCardUnreadAlertClassName = @@ -64,29 +58,6 @@ function overlayNewCardUnreadStatus( ) } -function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string { - const label = getReviewLabel(review) - if (review.state === 'merged') { - return `${label}: Merged` - } - if (review.state === 'closed') { - return `${label}: Closed` - } - if (review.state === 'draft') { - return `${label}: Draft` - } - if (review.status === 'failure') { - return `${label} checks: Failed` - } - if (review.status === 'pending') { - return `${label} checks: Pending` - } - if (review.status === 'success') { - return `${label} checks: Passing` - } - return `${label}: Open` -} - export function WorktreeCardStatusSlot({ worktreeId, showStatus, @@ -95,7 +66,6 @@ export function WorktreeCardStatusSlot({ unreadTooltip, onToggleUnread, onPointerDown, - prDisplay = null, newCardStyle = false, hasBranchIdentity = false, branchIdentityLabel, @@ -103,70 +73,43 @@ export function WorktreeCardStatusSlot({ }: WorktreeCardStatusSlotProps): React.JSX.Element | null { const status = useWorktreeActivityStatus(worktreeId) const statusLabel = getWorktreeStatusLabel(status) || status - const canShowReviewStatus = - newCardStyle && showStatus && prDisplay !== null && IDENTITY_REPLACEABLE_STATUSES.has(status) const canShowBranchStatus = - newCardStyle && - showStatus && - hasBranchIdentity && - prDisplay === null && - IDENTITY_REPLACEABLE_STATUSES.has(status) - const passiveStatusLabel = - canShowReviewStatus && prDisplay - ? getReviewStatusTooltip(prDisplay) - : canShowBranchStatus - ? (branchIdentityLabel ?? getDefaultBranchIdentityLabel()) - : statusLabel + newCardStyle && showStatus && hasBranchIdentity && status === 'inactive' + const passiveStatusLabel = canShowBranchStatus + ? (branchIdentityLabel ?? getDefaultBranchIdentityLabel()) + : statusLabel const passiveStatusTooltip = newCardStyle && isUnread ? `${passiveStatusLabel} · Unread` : passiveStatusLabel // Why: working and permission already own the new-card status lane, but // unread state should still surface in tooltip/sr-only copy and reappear afterward. const showNewCardUnreadAlert = newCardStyle && isUnread && showStatus && status !== 'working' && status !== 'permission' - const reviewStatusIconClassName = compactReviewAndBranchStatusIconClassName const branchStatusIcon =
) : null} diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index 8f204527c9a..8911c1783c5 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -192,7 +192,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('PR checks: Failed') }) - it('keeps the active dot ahead of PR status when new card style is on', () => { + it('uses PR status instead of the quiet active dot when new card style is on', () => { const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('Active') + expect(markup).toContain('PR checks: Failed') expect(markup).toContain('inline-flex size-5 items-center justify-center') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') - }) - - it('keeps the emerald activity dot ahead of branch identity when new card style is on', () => { - const markup = renderToStaticMarkup( - - ) - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('lucide-git-branch') + expect(markup).toContain('size-[13px] translate-x-px') + expect(markup).toContain('text-rose-500/85') + expect(markup).not.toContain('bg-emerald-500') }) it('uses the unified compact review glyph for GitLab MR status', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { expect(markup).not.toContain('lucide-git-merge') }) - it('keeps the done dot ahead of PR status when new card style is on', () => { + it('uses PR status instead of the quiet done dot when new card style is on', () => { mocks.status = 'done' const markup = renderToStaticMarkup( { /> ) - expect(markup).toContain('Done') - expect(markup).toContain('bg-emerald-500') - expect(markup).not.toContain('PR checks: Failed') + expect(markup).toContain('PR checks: Failed') + expect(markup).not.toContain('bg-emerald-500') }) it('uses PR status instead of the inactive dot when new card style is on', () => { @@ -298,8 +277,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('bg-neutral-500/40') }) - it('uses a branch icon with branch-only tooltip copy on quiet rows', () => { - mocks.status = 'inactive' + it('uses a branch icon with branch-only tooltip copy by default', () => { const markup = renderToStaticMarkup( { }) it('uses context-aware branch or folder path tooltip copy', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { }) it('overlays an unread badge on PR status when new card style is on', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( { }) it('overlays an unread badge on the branch icon in new card style', () => { - mocks.status = 'inactive' const markup = renderToStaticMarkup( (['inactive']) +const QUIET_REVIEW_REPLACEABLE_STATUSES = new Set(['active', 'done', 'inactive']) // Why: a missing review display can also mean provider state is unavailable, // so the passive label names the identity cue without claiming no review exists. function getDefaultBranchIdentityLabel(): string { @@ -104,13 +103,16 @@ export function WorktreeCardStatusSlot({ const status = useWorktreeActivityStatus(worktreeId) const statusLabel = getWorktreeStatusLabel(status) || status const canShowReviewStatus = - newCardStyle && showStatus && prDisplay !== null && IDENTITY_REPLACEABLE_STATUSES.has(status) + newCardStyle && + showStatus && + prDisplay !== null && + QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const canShowBranchStatus = newCardStyle && showStatus && hasBranchIdentity && prDisplay === null && - IDENTITY_REPLACEABLE_STATUSES.has(status) + QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const passiveStatusLabel = canShowReviewStatus && prDisplay ? getReviewStatusTooltip(prDisplay) diff --git a/tests/e2e/worktree-card-status-indicator.spec.ts b/tests/e2e/worktree-card-status-indicator.spec.ts deleted file mode 100644 index 78075cb26c2..00000000000 --- a/tests/e2e/worktree-card-status-indicator.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Regression coverage for active status taking precedence over passive identity (#8813). - -import type { Locator, Page } from '@stablyai/playwright-test' -import { test, expect } from './helpers/orca-app' -import { - waitForSessionReady, - waitForActiveWorktree, - getAllWorktreeIds, - ensureTerminalVisible -} from './helpers/store' -import { worktreeRow, worktreeRowSurface } from './worktree-row-locators' - -function statusLane(page: Page, worktreeId: string): Locator { - return worktreeRow(page, worktreeId).locator('[data-worktree-card-status-slot]').first() -} - -function statusDot(page: Page, worktreeId: string): Locator { - return statusLane(page, worktreeId).locator('span.bg-emerald-500').first() -} - -function branchIdentityGlyph(page: Page, worktreeId: string): Locator { - return statusLane(page, worktreeId).locator('svg.lucide-git-branch') -} - -// PTY liveness, not the tab row, drives the activity heuristic. -async function waitForLivePty(page: Page, worktreeId: string): Promise { - await expect - .poll( - async () => - page.evaluate((id) => { - const state = window.__store!.getState() - return (state.tabsByWorktree[id] ?? []).some( - (tab) => (state.ptyIdsByTabId[tab.id] ?? []).length > 0 - ) - }, worktreeId), - { timeout: 30_000, message: `No live PTY attached for worktree ${worktreeId}` } - ) - .toBe(true) -} - -test.describe('Worktree card status indicator', () => { - test.beforeEach(async ({ orcaPage }) => { - await waitForSessionReady(orcaPage) - await waitForActiveWorktree(orcaPage) - }) - - test('paints the emerald Active dot instead of the grey branch glyph once a workspace goes live', async ({ - orcaPage - }) => { - const liveWorktreeId = await waitForActiveWorktree(orcaPage) - await ensureTerminalVisible(orcaPage) - await waitForLivePty(orcaPage, liveWorktreeId) - - // The regression requires both the new card style and branch identity. - await orcaPage.evaluate(async () => { - const state = window.__store!.getState() - await state.updateSettings({ experimentalNewWorktreeCardStyle: true }) - state.setWorktreeCardProperties(['status', 'unread', 'branch']) - }) - - const quietWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== liveWorktreeId) - if (!quietWorktreeId) { - throw new Error('Seeded repo did not expose a second worktree to keep quiet') - } - - await expect(branchIdentityGlyph(orcaPage, quietWorktreeId)).toBeVisible() - await expect(statusLane(orcaPage, quietWorktreeId)).toHaveText('Branch') - - await expect(statusDot(orcaPage, liveWorktreeId)).toBeVisible() - await expect(statusLane(orcaPage, liveWorktreeId)).toHaveText('Active') - await expect(branchIdentityGlyph(orcaPage, liveWorktreeId)).toHaveCount(0) - - await worktreeRowSurface(orcaPage, quietWorktreeId).click() - await expect - .poll(async () => orcaPage.evaluate(() => window.__store!.getState().activeWorktreeId)) - .toBe(quietWorktreeId) - await ensureTerminalVisible(orcaPage) - await waitForLivePty(orcaPage, quietWorktreeId) - - await expect(statusDot(orcaPage, quietWorktreeId)).toBeVisible() - await expect(statusLane(orcaPage, quietWorktreeId)).toHaveText('Active') - await expect(branchIdentityGlyph(orcaPage, quietWorktreeId)).toHaveCount(0) - }) -}) From ff01fad4adda08423f657d10ecacaf682e0a1601 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:28:10 -0700 Subject: [PATCH 24/46] fix(runtime): stop a stalled tick from forgiving banked missed probes (#12841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge review of #12790 demonstrated a real leak: the resume-from-pause pardon cleared banked misses outright, so a host whose sweep stalls once every three ticks reset the budget forever and a dead socket was never reaped. The reviewer ran 300 sweeps against a permanently dead peer with a >1.5x gap every third tick and observed zero terminate() calls. Pre-#12790 that required a stall on *every* tick; the counter widened the pathological window 3x, and the failure mode is permanent non-reaping — the MAX_WS_CONNECTIONS leak the reaper exists to prevent. A stalled tick now charges no miss, which is all the original rationale needed (the client had no chance to answer that probe), but no longer forgives the misses already banked. A live client still clears its own count by answering the probe that is still sent on the stalled tick. The tolerance test's pause case is rewritten to assert the new contract rather than the old forgive-everything one, and a new test pins the leak directly: a host stalling every third tick must still reap a dead socket. Co-authored-by: Jinwoo-H --- ...r-heartbeat-missed-probe-tolerance.test.ts | 44 +++++++++++++++---- .../rpc/remote-runtime-server-heartbeat.ts | 15 +++---- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts index 2e3a3427175..665d94b0f41 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat-missed-probe-tolerance.test.ts @@ -53,7 +53,9 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { for (let probe = 0; probe < 6; probe += 1) { now += INTERVAL_MS await vi.advanceTimersByTimeAsync(INTERVAL_MS) - missesBeforeReap.push((socket.terminate as unknown as ReturnType).mock.calls.length) + missesBeforeReap.push( + (socket.terminate as unknown as ReturnType).mock.calls.length + ) } // Silence must be tolerated for more than a single probe, and must eventually end the socket. @@ -85,7 +87,7 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { heartbeat.stop() }) - it('clears accumulated misses when the server event loop resumes from a pause', async () => { + it('charges no miss for a paused tick but keeps the ones already banked', async () => { vi.useFakeTimers() let now = 1_000 const socket = makeSocket() @@ -114,8 +116,9 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { expect(toleratedMisses).toBeGreaterThan(1) heartbeat.stop() - // Replay on a fresh socket: bank misses to one short of the limit, suspend, then bank the same - // number again. The pause must write the earlier misses off, not top them up to the limit. + // Replay on a fresh socket: bank misses to one short of the limit, then suspend. The stalled tick + // must charge nothing — the client had no chance to answer it — but it must not forgive the misses + // already banked, or a host that stalls periodically could shelter a dead socket indefinitely. const resumed = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) const resumedSocket = makeSocket() resumed.noteAlive(resumedSocket) @@ -129,11 +132,36 @@ describe('RemoteRuntimeServerHeartbeat missed-probe tolerance', () => { now += 3_600_000 await vi.advanceTimersByTimeAsync(INTERVAL_MS) - for (let miss = 0; miss < toleratedMisses - 1; miss += 1) { - await sweep() - } - + // The suspend itself costs the socket nothing. expect(reaped(resumedSocket)).toBe(false) + + // But the pre-suspend evidence survives, so the very next unanswered probe completes the budget. + await sweep() + expect(reaped(resumedSocket)).toBe(true) resumed.stop() }) + + // Why: pre-fix the pardon cleared banked misses, so a host stalling once every few ticks reset the + // budget forever and a dead socket was never reaped — the connection leak the reaper exists to stop. + it('still reaps a dead socket when the host stalls repeatedly', async () => { + vi.useFakeTimers() + let now = 1_000 + const socket = makeSocket() + const heartbeat = new RemoteRuntimeServerHeartbeat(INTERVAL_MS, () => now) + heartbeat.noteAlive(socket) + heartbeat.start(() => [socket]) + heartbeat.noteAlive(socket) + + const reaped = (): boolean => + (socket.terminate as unknown as ReturnType).mock.calls.length > 0 + + // Stall every third tick — the worst case the old pardon could not survive. + for (let tick = 0; tick < 30 && !reaped(); tick += 1) { + now += tick % 3 === 2 ? INTERVAL_MS * 2 : INTERVAL_MS + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + } + + expect(reaped()).toBe(true) + heartbeat.stop() + }) }) diff --git a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts index 3913e9d9b6b..6134259c51f 100644 --- a/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts +++ b/src/main/runtime/rpc/remote-runtime-server-heartbeat.ts @@ -52,21 +52,20 @@ export class RemoteRuntimeServerHeartbeat { const tickAt = this.now() const elapsedMs = tickAt - (this.lastTickAt ?? tickAt) this.lastTickAt = tickAt - const resumedFromPause = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5 + // Why: a delayed tick cannot infer that clients missed a probe they had no chance to answer, so + // this sweep must not charge one. It must not *forgive* either: clearing banked misses lets a host + // that stalls once every few ticks protect a dead socket forever, which is the connection leak the + // reaper exists to prevent. Skipping the charge is enough — a live client clears its own count by + // answering the probe still sent below. + const stalledTick = elapsedMs < 0 || elapsedMs > this.intervalMs * 1.5 let reaped = 0 let clientCount = 0 for (const socket of clients) { clientCount += 1 - if (resumedFromPause) { - // Why: a delayed server tick cannot infer that clients missed a probe they had no chance to - // answer. Seeding alive also clears any misses banked before the pause, so a suspend can never - // top up a budget the client was never given a chance to defend. - this.alive.add(socket) - } if (this.alive.has(socket)) { this.alive.delete(socket) this.missedProbes.delete(socket) - } else { + } else if (!stalledTick) { const missed = (this.missedProbes.get(socket) ?? 0) + 1 if (missed >= this.missedProbeLimit) { this.missedProbes.delete(socket) From 81e7a2ddf776a35a420af41ec8ae55b25804c033 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:52:22 -0700 Subject: [PATCH 25/46] fix(terminal): recover live panes after renderer restart (STA-3536) (#12776) * fix(terminal): stop transient probe blips from erroring restored panes (STA-3536) terminal_pane_owner_unverified fired for every restored pane whenever one liveness probe answer went missing: a cold-start daemon draining an attach stampede misses the 2s getSize deadline, and a wedged superseded daemon (protocol upgrades leave them running) turns every unmapped fan-out probe null forever. - probePtyOwners now skips legacy daemons whose startup inventory listing succeeded: fresh sessions never route to them, so they provably don't own an unmapped id and one wedged zombie can't poison every pane's verdict. - attachStablePaneOwner retries the probe over a short backoff ladder before surfacing unverified, so a single missed deadline resolves to a verdict. - The renderer replaces the raw error code with actionable copy. * fix(terminal): stop retrying definitive owner probes * fix(terminal): recover live panes after renderer restart * refactor(terminal): share owner resolution abort guard --- src/main/daemon/client.ts | 8 +- src/main/daemon/daemon-errors.test.ts | 20 + src/main/daemon/daemon-errors.ts | 14 + .../daemon-pty-adapter-subscription-fanout.ts | 8 +- src/main/daemon/daemon-pty-liveness-probe.ts | 20 - src/main/daemon/daemon-pty-router.test.ts | 119 ++++- src/main/daemon/daemon-pty-router.ts | 48 +- .../daemon-session-owner-resolution.test.ts | 450 ++++++++++++++++++ .../daemon/daemon-session-owner-resolution.ts | 300 ++++++++++++ .../daemon/degraded-daemon-owner-recovery.ts | 82 ++++ .../degraded-daemon-pty-provider.test.ts | 49 +- .../daemon/degraded-daemon-pty-provider.ts | 46 +- .../daemon/degraded-daemon-session-routing.ts | 15 - src/main/ipc/pty.test.ts | 206 ++++++-- src/main/ipc/pty.ts | 128 ++++- src/main/persistence.test.ts | 215 +++++++++ src/main/persistence.ts | 34 +- src/main/providers/local-pty-provider.ts | 3 +- src/main/providers/pty-provider-contract.ts | 4 + .../terminal-pane/TerminalErrorToast.test.ts | 19 + .../terminal-pane/TerminalErrorToast.tsx | 19 +- src/renderer/src/i18n/locales/en.json | 3 +- 22 files changed, 1651 insertions(+), 159 deletions(-) create mode 100644 src/main/daemon/daemon-errors.test.ts delete mode 100644 src/main/daemon/daemon-pty-liveness-probe.ts create mode 100644 src/main/daemon/daemon-session-owner-resolution.test.ts create mode 100644 src/main/daemon/daemon-session-owner-resolution.ts create mode 100644 src/main/daemon/degraded-daemon-owner-recovery.ts diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index b6229521c8b..dd1d9066a02 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -18,6 +18,7 @@ import type { DaemonEvent } from './types' import { addNodePtyRecoveryHint } from './node-pty-error-hints' +import { decodeDaemonResponseError } from './daemon-errors' const CONNECT_TIMEOUT_MS = 5000 const CONNECTION_ATTEMPT_WAIT_MS = CONNECT_TIMEOUT_MS * 4 @@ -429,7 +430,12 @@ export class DaemonClient { if (response.ok) { pending.resolve(response.payload) } else { - pending.reject(new DaemonProtocolError(addNodePtyRecoveryHint(response.error))) + const decoded = decodeDaemonResponseError(response.error) + pending.reject( + decoded instanceof DaemonProtocolError + ? new DaemonProtocolError(addNodePtyRecoveryHint(response.error)) + : decoded + ) } } } diff --git a/src/main/daemon/daemon-errors.test.ts b/src/main/daemon/daemon-errors.test.ts new file mode 100644 index 00000000000..d12ce645ee7 --- /dev/null +++ b/src/main/daemon/daemon-errors.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { + DaemonProtocolError, + decodeDaemonResponseError, + SessionNotFoundError +} from './daemon-errors' + +describe('decodeDaemonResponseError', () => { + it('types the exact legacy session-absence response', () => { + expect(decodeDaemonResponseError('Session not found: pty-1')).toBeInstanceOf( + SessionNotFoundError + ) + }) + + it('keeps unrelated daemon failures non-authoritative', () => { + expect(decodeDaemonResponseError('proxy failed: Session not found: pty-1')).toBeInstanceOf( + DaemonProtocolError + ) + }) +}) diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts index 8077add319a..bddb3690a4a 100644 --- a/src/main/daemon/daemon-errors.ts +++ b/src/main/daemon/daemon-errors.ts @@ -20,3 +20,17 @@ export class SessionNotFoundError extends Error { this.name = 'SessionNotFoundError' } } + +export class TerminalSessionOwnerUnverifiedError extends Error { + constructor(sessionId: string) { + super(`Terminal session owner could not be verified: ${sessionId}`) + this.name = 'TerminalSessionOwnerUnverifiedError' + } +} + +export function decodeDaemonResponseError(message: string): Error { + const prefix = 'Session not found: ' + return message.startsWith(prefix) + ? new SessionNotFoundError(message.slice(prefix.length)) + : new DaemonProtocolError(message) +} diff --git a/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts index e4855640ada..1d82ef70b7b 100644 --- a/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts +++ b/src/main/daemon/daemon-pty-adapter-subscription-fanout.ts @@ -10,7 +10,8 @@ export class DaemonPtyAdapterSubscriptionFanout { constructor( private readonly adapters: readonly DaemonPtyAdapter[], - onAdapterExit: (id: string) => void + onAdapterExit: (id: string) => void, + onAdapterIdentityChanged?: (adapter: DaemonPtyAdapter) => void ) { for (const adapter of adapters) { this.unsubscribers.push( @@ -24,7 +25,10 @@ export class DaemonPtyAdapterSubscriptionFanout { for (const listener of this.exitListeners) { listener(payload) } - }) + }), + ...(onAdapterIdentityChanged && typeof adapter.onDaemonIdentityChanged === 'function' + ? [adapter.onDaemonIdentityChanged(() => onAdapterIdentityChanged(adapter))] + : []) ) } } diff --git a/src/main/daemon/daemon-pty-liveness-probe.ts b/src/main/daemon/daemon-pty-liveness-probe.ts deleted file mode 100644 index ba2c448bbd2..00000000000 --- a/src/main/daemon/daemon-pty-liveness-probe.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IPtyProvider } from '../providers/types' -import type { DaemonPtyAdapter } from './daemon-pty-adapter' - -export async function probePtyOwners( - id: string, - routed: IPtyProvider | undefined, - possibleOwners: readonly DaemonPtyAdapter[] -): Promise { - if (routed) { - return routed.probePtyLiveness - ? await routed.probePtyLiveness(id) - : (routed.hasPty?.(id) ?? null) - } - const results = await Promise.all(possibleOwners.map((provider) => provider.probePtyLiveness(id))) - return results.some((result) => result === true) - ? true - : results.every((result) => result === false) - ? false - : null -} diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 2bee5164ce9..1930224c2d5 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { DaemonPtyRouter } from './daemon-pty-router' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' import { @@ -18,6 +19,7 @@ type AdapterMock = DaemonPtyAdapter & { emitData: (id: string, data: string, sequenceChars?: number) => void emitBackground: (event: PtyBackgroundStreamEvent) => void emitExit: (id: string, code: number, incarnationId?: string) => void + emitIdentityChange: () => void triggerWriteUnavailable: (id: string) => void } @@ -44,6 +46,7 @@ function createAdapter( const writeUnavailableListeners: ((payload: { id: string }) => void)[] = [] const exitListeners: ((payload: { id: string; code: number; incarnationId?: string }) => void)[] = [] + const identityChangeListeners: (() => void)[] = [] return { protocolVersion, supportsGitCredentialGuardHost: () => @@ -136,6 +139,15 @@ function createAdapter( } } ), + onDaemonIdentityChanged: vi.fn((callback: () => void) => { + identityChangeListeners.push(callback) + return () => { + const idx = identityChangeListeners.indexOf(callback) + if (idx !== -1) { + identityChangeListeners.splice(idx, 1) + } + } + }), ackColdRestore: vi.fn(), clearTombstone: vi.fn(), reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), @@ -156,6 +168,7 @@ function createAdapter( listener({ id, code, ...(incarnationId ? { incarnationId } : {}) }) } }, + emitIdentityChange: () => identityChangeListeners.forEach((listener) => listener()), triggerWriteUnavailable: (id: string) => { for (const listener of writeUnavailableListeners) { listener({ id }) @@ -498,14 +511,16 @@ describe('DaemonPtyRouter', () => { expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session') }) - it('probes every possible daemon owner for an unmapped session', async () => { + it('discovers an unmapped live session from one coalesced inventory', async () => { const current = createAdapter('current') const legacy = createAdapter('legacy', ['surviving-session']) const router = new DaemonPtyRouter({ current, legacy: [legacy] }) await expect(router.probePtyLiveness('surviving-session')).resolves.toBe(true) - expect(current.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('surviving-session') - expect(legacy.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('surviving-session') + expect(current.listProcesses).toHaveBeenCalledOnce() + expect(legacy.listProcesses).toHaveBeenCalledOnce() + expect(current.probePtyLiveness).not.toHaveBeenCalled() + expect(legacy.probePtyLiveness).not.toHaveBeenCalled() }) it('does not report absence while any possible daemon owner is unavailable', async () => { @@ -517,6 +532,102 @@ describe('DaemonPtyRouter', () => { await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull() }) + it('routes an attach to a legacy session created after startup inventory', async () => { + const current = createAdapter('current') + const legacySessions = ['legacy-at-startup'] + const legacy = createAdapter('legacy', legacySessions) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + legacySessions.push('legacy-created-later') + + await router.spawn({ + sessionId: 'legacy-created-later', + attachOnly: true, + cols: 80, + rows: 24 + }) + + expect(legacy.spawn).toHaveBeenCalledExactlyOnceWith({ + sessionId: 'legacy-created-later', + attachOnly: true, + cols: 80, + rows: 24 + }) + expect(current.spawn).not.toHaveBeenCalled() + }) + + it('still routes probes for a discovered session to its owning legacy daemon', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session']) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + await expect(router.probePtyLiveness('legacy-session')).resolves.toBe(true) + expect(legacy.probePtyLiveness).toHaveBeenCalledExactlyOnceWith('legacy-session') + }) + + it('keeps consulting a legacy daemon whose inventory listing failed', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session']) + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged')) + vi.mocked(legacy.probePtyLiveness).mockResolvedValue(null) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + // Without an inventory nothing proves the legacy daemon doesn't own this id. + await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull() + expect(legacy.listProcesses).toHaveBeenCalledTimes(2) + expect(legacy.probePtyLiveness).not.toHaveBeenCalled() + }) + + it('keeps an attach unresolved when any possible owner inventory fails', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged')) + + await expect( + router.spawn({ sessionId: 'unknown-session', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(current.spawn).not.toHaveBeenCalled() + expect(legacy.spawn).not.toHaveBeenCalled() + }) + + it('re-resolves a stale positive route before declaring an owner absent', async () => { + const current = createAdapter('current') + const firstSessions = ['moved-session'] + const secondSessions: string[] = [] + const first = createAdapter('first', firstSessions) + const second = createAdapter('second', secondSessions) + const router = new DaemonPtyRouter({ current, legacy: [first, second] }) + await router.discoverLegacySessions() + firstSessions.length = 0 + secondSessions.push('moved-session') + vi.mocked(first.spawn).mockRejectedValueOnce(new SessionNotFoundError('moved-session')) + + await router.spawn({ sessionId: 'moved-session', attachOnly: true, cols: 80, rows: 24 }) + + expect(first.spawn).toHaveBeenCalledOnce() + expect(second.spawn).toHaveBeenCalledOnce() + }) + + it('invalidates positive routes when a daemon endpoint identity changes', async () => { + const currentSessions: string[] = [] + const legacySessions = ['moved-session'] + const current = createAdapter('current', currentSessions) + const legacy = createAdapter('legacy', legacySessions) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + legacySessions.length = 0 + currentSessions.push('moved-session') + + legacy.emitIdentityChange() + await router.spawn({ sessionId: 'moved-session', attachOnly: true, cols: 80, rows: 24 }) + + expect(current.spawn).toHaveBeenCalledOnce() + expect(legacy.spawn).not.toHaveBeenCalled() + }) + it('hands a checkpointed pre-v30 session to the current daemon on wake', async () => { const current = createAdapter('current', [], undefined, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) const legacy = createAdapter( @@ -582,7 +693,7 @@ describe('DaemonPtyRouter', () => { await expect(router.listProcesses()).rejects.toThrow('legacy exited') await expect(router.listProcesses()).rejects.toThrow('legacy exited') expect(router.getLegacyAdapters()).toEqual([legacy]) - expect(current.listProcesses).toHaveBeenCalledTimes(2) + expect(current.listProcesses).toHaveBeenCalledTimes(3) }) it('pins colliding unmapped legacy ids falling through to the current daemon', async () => { diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 0f07da4dc18..8429495ed9f 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -9,44 +9,44 @@ import type { PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' -import { probePtyOwners } from './daemon-pty-liveness-probe' import { shouldHandoffDaemonHistory } from './daemon-history-handoff' import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events' +import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter private legacy: DaemonPtyAdapter[] private sessionAdapters = new Map() + private readonly ownerResolver: DaemonSessionOwnerResolver private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { this.current = opts.current this.legacy = opts.legacy - this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(this.allAdapters(), (id) => { - this.sessionAdapters.delete(id) - }) + this.ownerResolver = new DaemonSessionOwnerResolver(this.allAdapters(), this.sessionAdapters) + this.subscriptions = new DaemonPtyAdapterSubscriptionFanout( + this.allAdapters(), + (id) => { + this.ownerResolver.forgetRoute(id) + }, + (adapter) => this.ownerResolver.invalidateProvider(adapter) + ) } async discoverLegacySessions(): Promise { - for (const adapter of this.legacy) { - try { - const sessions = await adapter.listProcesses() - for (const session of sessions) { - this.sessionAdapters.set(session.id, adapter) - } - } catch (error) { - console.warn('[daemon] Failed to discover legacy daemon sessions', error) - } - } + await this.ownerResolver.discoverRoutes() } async spawn(opts: PtySpawnOptions): Promise { + if (opts.attachOnly && opts.sessionId) { + return await this.ownerResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) + } const adapter = opts.sessionId ? this.sessionAdapters.get(opts.sessionId) : undefined const target = adapter ?? this.current const result = await target.spawn(opts) // Why: the adapter filters intentional recovery exits and canonical-ID races before publishing proof. if (!result.exitedBeforeSpawnReply) { - this.sessionAdapters.set(result.id, target) + this.ownerResolver.recordRoute(result.id, target, result.incarnationId) } return result } @@ -86,7 +86,7 @@ export class DaemonPtyRouter implements IPtyProvider { } async probePtyLiveness(id: string): Promise { - return await probePtyOwners(id, this.sessionAdapters.get(id), this.allAdapters()) + return await this.ownerResolver.probe(id) } write(id: string, data: string): void { @@ -121,7 +121,7 @@ export class DaemonPtyRouter implements IPtyProvider { adapter.ackColdRestore(id) } if (this.sessionAdapters.get(id) === adapter) { - this.sessionAdapters.delete(id) + this.ownerResolver.forgetRoute(id, adapter) } } } @@ -240,6 +240,7 @@ export class DaemonPtyRouter implements IPtyProvider { }> { const alive: string[] = [] const killed: string[] = [] + const aliveProviders = new Map>() for (const adapter of this.allAdapters()) { const result = await adapter.reconcileOnStartup(validWorktreeIds) // Why: daemon startup can reconcile many restored sessions; spreading @@ -251,10 +252,17 @@ export class DaemonPtyRouter implements IPtyProvider { killed.push(id) } for (const id of result.alive) { - this.sessionAdapters.set(id, adapter) + const providers = aliveProviders.get(id) ?? new Set() + providers.add(adapter) + aliveProviders.set(id, providers) } - for (const id of result.killed) { - this.sessionAdapters.delete(id) + } + for (const id of new Set([...alive, ...killed])) { + const providers = aliveProviders.get(id) + if (providers?.size === 1) { + this.ownerResolver.recordRoute(id, providers.values().next().value!) + } else { + this.ownerResolver.forgetRoute(id) } } return { alive, killed } diff --git a/src/main/daemon/daemon-session-owner-resolution.test.ts b/src/main/daemon/daemon-session-owner-resolution.test.ts new file mode 100644 index 00000000000..277c0750a84 --- /dev/null +++ b/src/main/daemon/daemon-session-owner-resolution.test.ts @@ -0,0 +1,450 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' +import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' + +function provider( + processes: () => Promise, + spawn: (opts: PtySpawnOptions) => Promise = async (opts) => ({ + id: opts.sessionId ?? 'new' + }) +): IPtyProvider { + return { + listProcesses: vi.fn(processes), + spawn: vi.fn(spawn) + } as unknown as IPtyProvider +} + +describe('DaemonSessionOwnerResolver', () => { + it('coalesces concurrent all-provider inventories', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const firstInventory = vi.fn(async () => { + await gate + return [] + }) + const secondInventory = vi.fn(async () => { + await gate + return [] + }) + const first = provider(firstInventory) + const second = provider(secondInventory) + const resolver = new DaemonSessionOwnerResolver([first, second], new Map()) + + const resolutions = Promise.all([resolver.resolve('one'), resolver.resolve('two')]) + await vi.waitFor(() => expect(firstInventory).toHaveBeenCalledOnce()) + expect(secondInventory).toHaveBeenCalledOnce() + release() + + await expect(resolutions).resolves.toEqual([{ kind: 'unknown' }, { kind: 'unknown' }]) + expect(firstInventory).toHaveBeenCalledOnce() + expect(secondInventory).toHaveBeenCalledOnce() + }) + + it('bounds restore inventory requests across many panes and daemon generations', async () => { + const inventories = Array.from({ length: 51 }, (_, index) => + vi.fn(async () => { + if (index === 50) { + throw new Error('offline') + } + return [] + }) + ) + const resolver = new DaemonSessionOwnerResolver( + inventories.map((inventory) => provider(inventory)), + new Map() + ) + + for (let index = 0; index < 40; index += 1) { + await resolver.resolve(`pane-${index}`) + } + + for (const inventory of inventories) { + expect(inventory).toHaveBeenCalledOnce() + } + }) + + it('reuses incomplete inventory across serialized restores', async () => { + const firstInventory = vi.fn(async () => []) + const secondInventory = vi.fn(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver( + [provider(firstInventory), provider(secondInventory)], + new Map() + ) + + await resolver.resolve('first') + await resolver.resolve('second') + + expect(firstInventory).toHaveBeenCalledOnce() + expect(secondInventory).toHaveBeenCalledOnce() + }) + + it('does not extend incomplete inventory lifetime on cache hits', async () => { + let now = 1_000 + const clock = vi.spyOn(Date, 'now').mockImplementation(() => now) + const inventory = vi.fn(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver( + [provider(async () => []), provider(inventory)], + new Map() + ) + + try { + await resolver.resolve('first') + now += 900 + await resolver.resolve('second') + now += 101 + await resolver.resolve('third') + + expect(inventory).toHaveBeenCalledTimes(2) + } finally { + clock.mockRestore() + } + }) + + it('does not let startup inventory hide a session created before restore', async () => { + const sessions: PtyProcessInfo[] = [] + const inventory = vi.fn(async () => sessions) + const owner = provider(inventory, async (opts) => ({ + id: opts.sessionId!, + incarnationId: 'live', + isReattach: true + })) + const resolver = new DaemonSessionOwnerResolver([owner, provider(async () => [])], new Map()) + + await resolver.discoverRoutes() + sessions.push({ id: 'session', incarnationId: 'live', cwd: '', title: 'live' }) + + await expect( + resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) + ).resolves.toMatchObject({ id: 'session', incarnationId: 'live', isReattach: true }) + expect(inventory).toHaveBeenCalledTimes(2) + }) + + it('uses the expected incarnation to disambiguate duplicate session ids', async () => { + const oldOwner = provider(async () => [ + { id: 'session', incarnationId: 'old', cwd: '', title: 'old' } + ]) + const exactOwner = provider(async () => [ + { id: 'session', incarnationId: 'expected', cwd: '', title: 'exact' } + ]) + const resolver = new DaemonSessionOwnerResolver([oldOwner, exactOwner], new Map()) + + await expect(resolver.resolve('session', 'expected')).resolves.toMatchObject({ + kind: 'owner', + provider: exactOwner + }) + }) + + it('accepts one exact-id owner when persisted incarnation evidence is stale', async () => { + const liveOwner = provider(async () => [ + { id: 'session', incarnationId: 'live', cwd: '', title: 'live' } + ]) + const resolver = new DaemonSessionOwnerResolver([liveOwner], new Map()) + + await expect(resolver.resolve('session', 'stale')).resolves.toMatchObject({ + kind: 'owner', + provider: liveOwner + }) + }) + + it('does not inventory unrelated providers for a routed stale persisted incarnation', async () => { + const routedOwner = provider( + async () => [{ id: 'session', incarnationId: 'live', cwd: '', title: 'live' }], + async () => ({ id: 'session', incarnationId: 'live', isReattach: true }) + ) + const unrelatedInventory = vi.fn(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver( + [routedOwner, provider(unrelatedInventory)], + new Map([['session', routedOwner]]) + ) + + await expect( + resolver.spawnAttachOnly({ + sessionId: 'session', + expectedIncarnationId: 'stale', + attachOnly: true, + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ id: 'session', incarnationId: 'live', isReattach: true }) + expect(unrelatedInventory).not.toHaveBeenCalled() + }) + + it('re-resolves a routed incarnation mismatch against every possible owner', async () => { + const staleRoute = provider( + async () => [{ id: 'session', incarnationId: 'other', cwd: '', title: 'stale' }], + async () => ({ id: 'session', incarnationId: 'other', isReattach: true }) + ) + const exactOwner = provider( + async () => [{ id: 'session', incarnationId: 'expected', cwd: '', title: 'exact' }], + async () => ({ id: 'session', incarnationId: 'expected', isReattach: true }) + ) + const resolver = new DaemonSessionOwnerResolver( + [staleRoute, exactOwner], + new Map([['session', staleRoute]]) + ) + + await expect( + resolver.spawnAttachOnly({ + sessionId: 'session', + expectedIncarnationId: 'expected', + expectedIncarnationIsAuthoritative: true, + attachOnly: true, + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ incarnationId: 'expected' }) + expect(staleRoute.spawn).not.toHaveBeenCalled() + }) + + it('refreshes incomplete inventory when a routed owner refuses a moved session', async () => { + let firstOwnsSession = true + let secondOwnsSession = false + const first = provider( + async () => + firstOwnsSession + ? [{ id: 'session', incarnationId: 'runtime', cwd: '', title: 'first' }] + : [], + async () => { + throw new SessionNotFoundError('session') + } + ) + const second = provider( + async () => + secondOwnsSession + ? [{ id: 'session', incarnationId: 'runtime', cwd: '', title: 'second' }] + : [], + async () => ({ id: 'session', incarnationId: 'runtime', isReattach: true }) + ) + const unavailable = provider(async () => { + throw new Error('offline') + }) + const resolver = new DaemonSessionOwnerResolver([first, second, unavailable], new Map()) + + await expect(resolver.resolve('session', 'runtime', true)).resolves.toMatchObject({ + kind: 'owner', + provider: first + }) + firstOwnsSession = false + secondOwnsSession = true + + await expect( + resolver.spawnAttachOnly({ + sessionId: 'session', + expectedIncarnationId: 'runtime', + expectedIncarnationIsAuthoritative: true, + attachOnly: true, + cols: 80, + rows: 24 + }) + ).resolves.toMatchObject({ id: 'session', incarnationId: 'runtime', isReattach: true }) + expect(first.spawn).toHaveBeenCalledOnce() + expect(second.spawn).toHaveBeenCalledOnce() + expect(second.listProcesses).toHaveBeenCalledTimes(2) + }) + + it('accepts exact runtime proof from an incomplete inventory', async () => { + const exactOwner = provider(async () => [ + { id: 'session', incarnationId: 'runtime', cwd: '', title: 'exact' } + ]) + const resolver = new DaemonSessionOwnerResolver( + [ + exactOwner, + provider(async () => { + throw new Error('offline') + }) + ], + new Map() + ) + + await expect(resolver.resolve('session', 'runtime', true)).resolves.toMatchObject({ + kind: 'owner', + provider: exactOwner + }) + }) + + it('does not trust persisted incarnation proof from an incomplete inventory', async () => { + const resolver = new DaemonSessionOwnerResolver( + [ + provider(async () => [ + { id: 'session', incarnationId: 'persisted', cwd: '', title: 'candidate' } + ]), + provider(async () => { + throw new Error('offline') + }) + ], + new Map() + ) + + await expect(resolver.resolve('session', 'persisted')).resolves.toEqual({ kind: 'unknown' }) + }) + + it('accepts positive liveness proof from an incomplete inventory', async () => { + const resolver = new DaemonSessionOwnerResolver( + [ + provider(async () => [{ id: 'session', cwd: '', title: 'live' }]), + provider(async () => { + throw new Error('offline') + }) + ], + new Map() + ) + + await expect(resolver.probe('session')).resolves.toBe(true) + }) + + it('fails closed when duplicate owners cannot be disambiguated', async () => { + const first = provider(async () => [{ id: 'session', cwd: '', title: 'first' }]) + const second = provider(async () => [{ id: 'session', cwd: '', title: 'second' }]) + const resolver = new DaemonSessionOwnerResolver([first, second], new Map()) + + await expect(resolver.resolve('session')).resolves.toEqual({ kind: 'unknown' }) + }) + + it('reports confirmed absence from a sole owner', async () => { + const owner = provider( + async () => [], + async () => { + throw new SessionNotFoundError('missing') + } + ) + const resolver = new DaemonSessionOwnerResolver([owner], new Map()) + + await expect( + resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(SessionNotFoundError) + expect(owner.spawn).toHaveBeenCalledOnce() + expect(owner.listProcesses).not.toHaveBeenCalled() + }) + + it('keeps absence unverified when there are no possible owners', async () => { + const resolver = new DaemonSessionOwnerResolver([], new Map()) + + await expect(resolver.resolve('missing')).resolves.toEqual({ kind: 'unknown' }) + }) + + it('preserves an unresolved owner when any provider inventory fails', async () => { + const resolver = new DaemonSessionOwnerResolver( + [ + provider(async () => []), + provider(async () => { + throw new Error('offline') + }) + ], + new Map() + ) + + await expect( + resolver.spawnAttachOnly({ sessionId: 'missing', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + }) + + it('does not turn a raced owner refusal into absence while another provider is unresolved', async () => { + const candidate = provider( + async () => [{ id: 'session', cwd: '', title: 'candidate' }], + async () => { + throw new SessionNotFoundError('session') + } + ) + const resolver = new DaemonSessionOwnerResolver( + [ + candidate, + provider(async () => { + throw new Error('offline') + }) + ], + new Map() + ) + + await expect( + resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + }) + + it('does not turn a post-inventory owner refusal into aggregate absence', async () => { + const candidate = provider( + async () => [{ id: 'session', cwd: '', title: 'candidate' }], + async () => { + throw new SessionNotFoundError('session') + } + ) + const resolver = new DaemonSessionOwnerResolver( + [candidate, provider(async () => [])], + new Map() + ) + + await expect( + resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + }) + + it('pre-routes every uniquely inventoried session for serialized restores', async () => { + const inventory = vi.fn(async () => [ + { id: 'first', cwd: '', title: 'first' }, + { id: 'second', cwd: '', title: 'second' } + ]) + const owner = provider(inventory) + const emptyInventory = vi.fn(async () => []) + const resolver = new DaemonSessionOwnerResolver([owner, provider(emptyInventory)], new Map()) + + await resolver.spawnAttachOnly({ sessionId: 'first', attachOnly: true, cols: 80, rows: 24 }) + await resolver.spawnAttachOnly({ sessionId: 'second', attachOnly: true, cols: 80, rows: 24 }) + + expect(inventory).toHaveBeenCalledOnce() + expect(emptyInventory).toHaveBeenCalledOnce() + }) + + it('discards an inventory completed after daemon identity replacement', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const inventory = vi.fn(async () => { + await gate + return [{ id: 'session', cwd: '', title: 'stale' }] + }) + const owner = provider(inventory) + const resolver = new DaemonSessionOwnerResolver([owner], new Map()) + + const resolution = resolver.resolve('session') + await vi.waitFor(() => expect(inventory).toHaveBeenCalledOnce()) + resolver.invalidateProvider(owner) + release() + + await expect(resolution).resolves.toEqual({ kind: 'unknown' }) + await resolver.resolve('session') + expect(inventory).toHaveBeenCalledTimes(2) + }) + + it('restores a proven route when identity changes during exact reattach', async () => { + const routes = new Map() + let resolver!: DaemonSessionOwnerResolver + let owner!: IPtyProvider + owner = provider( + async () => [], + async () => { + resolver.invalidateProvider(owner) + return { id: 'session', incarnationId: 'inc', isReattach: true } + } + ) + routes.set('session', owner) + resolver = new DaemonSessionOwnerResolver([owner], routes) + + await resolver.spawnAttachOnly({ sessionId: 'session', attachOnly: true, cols: 80, rows: 24 }) + + expect(routes.get('session')).toBe(owner) + }) +}) diff --git a/src/main/daemon/daemon-session-owner-resolution.ts b/src/main/daemon/daemon-session-owner-resolution.ts new file mode 100644 index 00000000000..d35d2795d56 --- /dev/null +++ b/src/main/daemon/daemon-session-owner-resolution.ts @@ -0,0 +1,300 @@ +import type { + IPtyProvider, + PtyProcessInfo, + PtySpawnOptions, + PtySpawnResult +} from '../providers/types' +import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors' + +export type DaemonSessionOwnerResolution = + | { kind: 'owner'; provider: T } + | { kind: 'absent' } + | { kind: 'unknown' } + +type ProviderInventory = { provider: T; processes: PtyProcessInfo[] | null } + +type OwnerInventory = { + candidatesBySessionId: Map + complete: boolean + epoch: number +} + +const OWNER_RESOLUTION_TIMEOUT_MS = 2_000 +const OWNER_INVENTORY_CACHE_MS = 1_000 +const FAILED_PROVIDER_COOLDOWN_MS = 1_000 + +function assertClientConnected(signal: PtySpawnOptions['signal']): void { + if (signal?.aborted) { + throw new Error('client_disconnected') + } +} + +export class DaemonSessionOwnerResolver { + private inventoryInFlight: Promise> | null = null + private cachedInventory: { value: OwnerInventory; expiresAt: number } | null = null + private readonly failedProviderCooldowns = new Map() + private readonly routeIncarnations = new Map() + private epoch = 0 + + constructor( + private readonly providers: readonly T[], + private readonly routes: Map + ) {} + + invalidateProvider(provider: T): void { + this.epoch += 1 + this.inventoryInFlight = null + this.cachedInventory = null + this.failedProviderCooldowns.clear() + for (const [sessionId, routed] of this.routes) { + if (routed === provider) { + this.routes.delete(sessionId) + this.routeIncarnations.delete(sessionId) + } + } + } + + async spawnAttachOnly(opts: PtySpawnOptions & { sessionId: string }): Promise { + assertClientConnected(opts.signal) + const routed = this.providers.find((provider) => provider === this.routes.get(opts.sessionId)) + const direct = routed ?? (this.providers.length === 1 ? this.providers[0] : undefined) + const routedIncarnation = this.routeIncarnations.get(opts.sessionId) + const routeNeedsAuthoritativeResolution = + direct && + routed && + opts.expectedIncarnationIsAuthoritative === true && + routedIncarnation !== opts.expectedIncarnationId + if (direct && !routeNeedsAuthoritativeResolution) { + try { + const result = await direct.spawn(opts) + if ( + !result.exitedBeforeSpawnReply && + result.id === opts.sessionId && + result.isReattach === true + ) { + this.recordRoute(result.id, direct, result.incarnationId) + } + return result + } catch (error) { + if (!(error instanceof SessionNotFoundError)) { + throw error + } + if (this.providers.length === 1) { + throw error + } + if (routed && this.routes.get(opts.sessionId) === routed) { + this.forgetRoute(opts.sessionId, routed) + } + } + } + + assertClientConnected(opts.signal) + const resolution = await this.resolve( + opts.sessionId, + opts.expectedIncarnationId, + opts.expectedIncarnationIsAuthoritative + ) + assertClientConnected(opts.signal) + if (resolution.kind === 'unknown') { + throw new TerminalSessionOwnerUnverifiedError(opts.sessionId) + } + if (resolution.kind === 'absent') { + throw new SessionNotFoundError(opts.sessionId) + } + try { + const result = await resolution.provider.spawn(opts) + if ( + !result.exitedBeforeSpawnReply && + result.id === opts.sessionId && + result.isReattach === true + ) { + this.recordRoute(result.id, resolution.provider, result.incarnationId) + } + return result + } catch (error) { + if (error instanceof SessionNotFoundError && this.providers.length > 1) { + throw new TerminalSessionOwnerUnverifiedError(opts.sessionId) + } + throw error + } + } + + async probe(sessionId: string): Promise { + const routed = this.providers.find((provider) => provider === this.routes.get(sessionId)) + const direct = routed ?? (this.providers.length === 1 ? this.providers[0] : undefined) + if (direct) { + const verdict = direct.probePtyLiveness + ? await direct.probePtyLiveness(sessionId) + : (direct.hasPty?.(sessionId) ?? null) + if (verdict !== false || this.providers.length === 1) { + return verdict + } + if (this.routes.get(sessionId) === routed) { + this.routes.delete(sessionId) + this.routeIncarnations.delete(sessionId) + } + } + const inventory = await this.inventory(false) + if (inventory.epoch !== this.epoch) { + return null + } + if ((inventory.candidatesBySessionId.get(sessionId)?.length ?? 0) > 0) { + return true + } + const resolution = this.resolveInventory(inventory, sessionId) + if (resolution.kind === 'owner') { + return resolution.provider === routed ? null : true + } + return resolution.kind === 'absent' ? false : null + } + + async resolve( + sessionId: string, + expectedIncarnationId?: string, + expectedIncarnationIsAuthoritative = false + ): Promise> { + const inventory = await this.inventory() + if (inventory.epoch !== this.epoch) { + return { kind: 'unknown' } + } + const resolution = this.resolveInventory( + inventory, + sessionId, + expectedIncarnationId, + expectedIncarnationIsAuthoritative + ) + if ( + (!inventory.complete || resolution.kind === 'unknown') && + this.cachedInventory?.value !== inventory + ) { + this.cachedInventory = { + value: inventory, + expiresAt: Date.now() + OWNER_INVENTORY_CACHE_MS + } + } + return resolution + } + + async discoverRoutes(): Promise { + await this.inventory() + this.failedProviderCooldowns.clear() + this.cachedInventory = null + } + + recordRoute(sessionId: string, provider: T, incarnationId?: string): void { + this.routes.set(sessionId, provider) + this.routeIncarnations.set(sessionId, incarnationId) + } + + forgetRoute(sessionId: string, provider?: T): void { + if (provider && this.routes.get(sessionId) !== provider) { + return + } + this.routes.delete(sessionId) + this.routeIncarnations.delete(sessionId) + this.cachedInventory = null + } + + private resolveInventory( + inventory: OwnerInventory, + sessionId: string, + expectedIncarnationId?: string, + expectedIncarnationIsAuthoritative = false + ): DaemonSessionOwnerResolution { + const candidates = inventory.candidatesBySessionId.get(sessionId) ?? [] + const providers = new Set(candidates.map(({ provider }) => provider)) + const exactProviders = new Set( + candidates + .filter(({ process }) => process.incarnationId === expectedIncarnationId) + .map(({ provider }) => provider) + ) + const exactProvider = + expectedIncarnationId && exactProviders.size === 1 + ? exactProviders.values().next().value + : undefined + const soleProvider = providers.size === 1 ? providers.values().next().value : undefined + const provider = + (exactProvider && (inventory.complete || expectedIncarnationIsAuthoritative) + ? exactProvider + : undefined) ?? + (!expectedIncarnationIsAuthoritative && inventory.complete ? soleProvider : undefined) + if (provider) { + const process = candidates.find((candidate) => candidate.provider === provider)?.process + this.recordRoute(sessionId, provider, process?.incarnationId) + return { kind: 'owner', provider } + } + if (!inventory.complete || providers.size > 1 || this.providers.length !== 1) { + return { kind: 'unknown' } + } + return { kind: 'absent' } + } + + private inventory(allowCached = true): Promise> { + if (this.inventoryInFlight) { + return this.inventoryInFlight + } + if ( + allowCached && + this.cachedInventory?.value.epoch === this.epoch && + this.cachedInventory.expiresAt > Date.now() + ) { + return Promise.resolve(this.cachedInventory.value) + } + this.cachedInventory = null + const deadlineMs = Date.now() + OWNER_RESOLUTION_TIMEOUT_MS + const epoch = this.epoch + const inventory = Promise.all( + this.providers.map(async (provider): Promise> => { + if ((this.failedProviderCooldowns.get(provider) ?? 0) > Date.now()) { + return { provider, processes: null } + } + this.failedProviderCooldowns.delete(provider) + try { + return { provider, processes: await provider.listProcesses({ deadlineMs }) } + } catch { + if (epoch === this.epoch) { + this.failedProviderCooldowns.set(provider, Date.now() + FAILED_PROVIDER_COOLDOWN_MS) + } + return { provider, processes: null } + } + }) + ) + .then((entries) => this.indexInventory(entries, epoch)) + .finally(() => { + if (this.inventoryInFlight === inventory) { + this.inventoryInFlight = null + } + }) + this.inventoryInFlight = inventory + return inventory + } + + private indexInventory(entries: ProviderInventory[], epoch: number): OwnerInventory { + const candidatesBySessionId = new Map() + let complete = true + for (const entry of entries) { + if (!entry.processes) { + complete = false + continue + } + for (const process of entry.processes) { + const candidates = candidatesBySessionId.get(process.id) ?? [] + candidates.push({ provider: entry.provider, process }) + candidatesBySessionId.set(process.id, candidates) + } + } + if (complete && epoch === this.epoch) { + for (const [sessionId, candidates] of candidatesBySessionId) { + const providers = new Set(candidates.map(({ provider }) => provider)) + if (providers.size === 1) { + const provider = providers.values().next().value! + const process = candidates.find((candidate) => candidate.provider === provider)?.process + this.recordRoute(sessionId, provider, process?.incarnationId) + } else { + this.forgetRoute(sessionId) + } + } + } + return { candidatesBySessionId, complete, epoch } + } +} diff --git a/src/main/daemon/degraded-daemon-owner-recovery.ts b/src/main/daemon/degraded-daemon-owner-recovery.ts new file mode 100644 index 00000000000..ec8798a5ce3 --- /dev/null +++ b/src/main/daemon/degraded-daemon-owner-recovery.ts @@ -0,0 +1,82 @@ +import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution' + +export class DegradedDaemonOwnerRecovery { + private readonly attachResolver: DaemonSessionOwnerResolver + private readonly livenessResolver: DaemonSessionOwnerResolver + + constructor( + providers: readonly IPtyProvider[], + private readonly daemonAdapters: readonly DaemonPtyAdapter[], + routes: Map, + private readonly spawnFresh: (opts: PtySpawnOptions) => Promise + ) { + this.attachResolver = new DaemonSessionOwnerResolver(providers, routes) + this.livenessResolver = new DaemonSessionOwnerResolver(daemonAdapters, routes) + } + + spawn = async (opts: PtySpawnOptions): Promise => { + return opts.attachOnly && opts.sessionId + ? await this.attachResolver.spawnAttachOnly({ ...opts, sessionId: opts.sessionId }) + : await this.spawnFresh(opts) + } + + probe = async (sessionId: string): Promise => + await this.livenessResolver.probe(sessionId) + + discoverRoutes = async (): Promise => await this.attachResolver.discoverRoutes() + + async reconcileOnStartup(validWorktreeIds: Set): Promise<{ + alive: string[] + killed: string[] + }> { + const alive: string[] = [] + const killed: string[] = [] + const aliveProviders = new Map>() + for (const adapter of this.daemonAdapters) { + const result = await adapter.reconcileOnStartup(validWorktreeIds) + for (const id of result.alive) { + alive.push(id) + const providers = aliveProviders.get(id) ?? new Set() + providers.add(adapter) + aliveProviders.set(id, providers) + } + killed.push(...result.killed) + } + for (const id of new Set([...alive, ...killed])) { + const providers = aliveProviders.get(id) + if (providers?.size === 1) { + this.recordRoute(id, providers.values().next().value!) + } else { + this.forgetRoute(id) + } + } + return { alive, killed } + } + + recordRoute(sessionId: string, provider: IPtyProvider): void { + this.attachResolver.recordRoute(sessionId, provider) + if (this.daemonAdapters.includes(provider as DaemonPtyAdapter)) { + this.livenessResolver.recordRoute(sessionId, provider as DaemonPtyAdapter) + } + } + + forgetRoute(sessionId: string): void { + this.attachResolver.forgetRoute(sessionId) + this.livenessResolver.forgetRoute(sessionId) + } + + subscribeIdentityChanges(): (() => void)[] { + return this.daemonAdapters.flatMap((adapter) => + typeof adapter.onDaemonIdentityChanged === 'function' + ? [ + adapter.onDaemonIdentityChanged(() => { + this.attachResolver.invalidateProvider(adapter) + this.livenessResolver.invalidateProvider(adapter) + }) + ] + : [] + ) + } +} diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 644f732be02..af7c30d1c5d 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -4,6 +4,7 @@ import { DEGRADED_DAEMON_RECOVERY_RETRY_MS } from './degraded-daemon-fresh-spawn import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import { TerminalSessionOwnerUnverifiedError } from './daemon-errors' type ProviderMock = IPtyProvider & { probePtyLiveness: (id: string) => Promise @@ -158,6 +159,47 @@ it('forwards dead-endpoint write-unavailable signals from the daemon adapters', expect(recovered).toEqual(['daemon-pane', 'legacy-pane']) }) +it('routes attach-only to a legacy session created after startup inventory', async () => { + const current = createDaemonAdapter('daemon') + const legacySessions = ['legacy-at-startup'] + const legacy = createDaemonAdapter('legacy', legacySessions) + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ + current, + legacy: [legacy], + fallback + }) + await provider.discoverDaemonSessions() + legacySessions.push('legacy-created-later') + + await provider.spawn({ + sessionId: 'legacy-created-later', + attachOnly: true, + cols: 80, + rows: 24 + }) + + expect(legacy.spawn).toHaveBeenCalledOnce() + expect(current.spawn).not.toHaveBeenCalled() + expect(fallback.spawn).not.toHaveBeenCalled() +}) + +it('keeps an attach unresolved when a legacy inventory listing fails', async () => { + const current = createDaemonAdapter('daemon') + const legacy = createDaemonAdapter('legacy') + vi.mocked(legacy.listProcesses).mockRejectedValue(new Error('wedged')) + const provider = new DegradedDaemonPtyProvider({ + current, + legacy: [legacy], + fallback: createProvider('fallback') + }) + + await expect( + provider.spawn({ sessionId: 'unknown-session', attachOnly: true, cols: 80, rows: 24 }) + ).rejects.toBeInstanceOf(TerminalSessionOwnerUnverifiedError) + expect(current.spawn).not.toHaveBeenCalled() +}) + it('rejects completion inspection instead of borrowing the fallback provider', async () => { const provider = new DegradedDaemonPtyProvider({ current: createDaemonAdapter('daemon'), @@ -368,7 +410,8 @@ describe('DegradedDaemonPtyProvider', () => { }) it('probes daemon owners without borrowing fallback liveness', async () => { - const current = createDaemonAdapter('current') + const currentSessions: string[] = [] + const current = createDaemonAdapter('current', currentSessions) const legacy = createDaemonAdapter('legacy') const fallback = createProvider('fallback', ['unknown-session']) const provider = new DegradedDaemonPtyProvider({ current, legacy: [legacy], fallback }) @@ -377,7 +420,7 @@ describe('DegradedDaemonPtyProvider', () => { await expect(provider.probePtyLiveness('unknown-session')).resolves.toBeNull() expect(fallback.probePtyLiveness).not.toHaveBeenCalled() - vi.mocked(current.probePtyLiveness).mockResolvedValue(true) + currentSessions.push('unknown-session') await expect(provider.probePtyLiveness('unknown-session')).resolves.toBe(true) }) @@ -529,6 +572,6 @@ describe('DegradedDaemonPtyProvider', () => { await expect(provider.listProcesses()).rejects.toThrow('legacy exited') expect(provider.getLegacyAdapters()).toEqual([legacy]) expect(current.listProcesses).toHaveBeenCalledTimes(3) - expect(fallback.listProcesses).toHaveBeenCalledTimes(2) + expect(fallback.listProcesses).toHaveBeenCalledTimes(3) }) }) diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index 4fef029f03b..6b1c738b316 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -14,12 +14,11 @@ import type { import { adoptOwningProvider, attachDaemonOwnedSession, - discoverDegradedDaemonSessions, findDaemonAdapter, listProviderSessionIds } from './degraded-daemon-session-routing' -import { probePtyOwners } from './daemon-pty-liveness-probe' import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing' +import { DegradedDaemonOwnerRecovery } from './degraded-daemon-owner-recovery' export class DegradedDaemonPtyProvider implements IPtyProvider { readonly isDegraded = true @@ -29,6 +28,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { private fallback: IPtyProvider private sessionProviders = new Map() private freshSpawns: DegradedDaemonFreshSpawnRouter + private ownerRecovery: DegradedDaemonOwnerRecovery private unsubscribers: (() => void)[] = [] private dataListeners: ((payload: PtyDataEvent) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] @@ -48,26 +48,27 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { this.sessionProviders, opts.probeCurrentDaemonSpawn ?? null ) + this.ownerRecovery = new DegradedDaemonOwnerRecovery( + this.allProviders(), + this.allDaemonAdapters(), + this.sessionProviders, + (spawnOpts) => this.freshSpawns.spawn(spawnOpts) + ) for (const provider of this.allProviders()) { this.unsubscribers.push( - provider.onData((payload) => { - for (const listener of this.dataListeners) { - listener(payload) - } - }), + provider.onData((payload) => this.dataListeners.forEach((listener) => listener(payload))), provider.onExit((payload) => { - this.sessionProviders.delete(payload.id) - for (const listener of this.exitListeners) { - listener(payload) - } + this.ownerRecovery.forgetRoute(payload.id) + this.exitListeners.forEach((listener) => listener(payload)) }) ) } + this.unsubscribers.push(...this.ownerRecovery.subscribeIdentityChanges()) } - discoverDaemonSessions(): Promise { - return discoverDegradedDaemonSessions(this.allDaemonAdapters(), this.sessionProviders) + async discoverDaemonSessions(): Promise { + await this.ownerRecovery.discoverRoutes() } get routesFreshSpawnsToLocalProvider(): true | undefined { @@ -82,7 +83,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { canProvideAuthoritativeBufferSnapshot = (id: string): boolean => this.freshSpawns.canProvideSnapshot(id) - spawn = (opts: PtySpawnOptions): Promise => this.freshSpawns.spawn(opts) + spawn = (opts: PtySpawnOptions): Promise => this.ownerRecovery.spawn(opts) // Why refuse the fallback route (unknown ids resolve to it): see attachDaemonOwnedSession. attach = (id: string): Promise => @@ -94,7 +95,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async probePtyLiveness(id: string): Promise { - return await probePtyOwners(id, this.sessionProviders.get(id), this.allDaemonAdapters()) + return await this.ownerRecovery.probe(id) } // Why: an unknown id cannot borrow listing authority from the fresh-spawn provider. @@ -275,20 +276,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { alive: string[] killed: string[] }> { - const alive: string[] = [] - const killed: string[] = [] - for (const adapter of this.allDaemonAdapters()) { - const result = await adapter.reconcileOnStartup(validWorktreeIds) - for (const id of result.alive) { - alive.push(id) - this.sessionProviders.set(id, adapter) - } - for (const id of result.killed) { - killed.push(id) - this.sessionProviders.delete(id) - } - } - return { alive, killed } + return await this.ownerRecovery.reconcileOnStartup(validWorktreeIds) } dispose(): void { diff --git a/src/main/daemon/degraded-daemon-session-routing.ts b/src/main/daemon/degraded-daemon-session-routing.ts index 8f0ef61d22b..688098c05f3 100644 --- a/src/main/daemon/degraded-daemon-session-routing.ts +++ b/src/main/daemon/degraded-daemon-session-routing.ts @@ -2,21 +2,6 @@ import type { IPtyProvider } from '../providers/types' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { SessionNotFoundError } from './daemon-errors' -export async function discoverDegradedDaemonSessions( - adapters: readonly DaemonPtyAdapter[], - sessionProviders: Map -): Promise { - for (const adapter of adapters) { - try { - for (const session of await adapter.listProcesses()) { - sessionProviders.set(session.id, adapter) - } - } catch (error) { - console.warn('[daemon] Failed to discover degraded daemon sessions', error) - } - } -} - export function listProviderSessionIds( sessionProviders: ReadonlyMap, provider: IPtyProvider diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index c91c7d23dfd..877d7d8862c 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -14,6 +14,7 @@ import type { TuiAgent } from '../../shared/types' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' +import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' const isWindowsHost = process.platform === 'win32' const posixOnlyIt = isWindowsHost ? it.skip : it @@ -9009,14 +9010,15 @@ describe('registerPtyHandlers', () => { } ) - it('adopts an exact persisted owner when the runtime projection is missing', async () => { + it('repairs a stale persisted incarnation after exact same-id reattach', async () => { const tabId = 'tab-persisted-owner' const leafId = '88888888-8888-4888-8888-888888888888' const paneKey = makePaneKey(tabId, leafId) const worktreeId = 'repo-1::/tmp/persisted-owner' + let attachAttempt = 0 const providerSpawn = vi.fn(async (options: { attachOnly?: boolean; sessionId?: string }) => ({ id: options.sessionId ?? 'unexpected-fresh-id', - incarnationId: 'inc-persisted-owner', + incarnationId: attachAttempt++ === 1 ? 'inc-wrong-owner' : 'inc-live-owner', isReattach: options.attachOnly === true, snapshot: 'persisted-owner-output' })) @@ -9067,10 +9069,10 @@ describe('registerPtyHandlers', () => { [tabId]: { ptyIdsByLeafId: { [leafId]: 'pty-persisted-owner' } } }, terminalPtyIncarnationsByPaneKey: { - [paneKey]: 'inc-persisted-owner' + [paneKey]: 'inc-stale-owner' } })), - persistPtyBinding: vi.fn() + persistPtyBinding: vi.fn(() => true) } registerPtyHandlers( @@ -9081,7 +9083,7 @@ describe('registerPtyHandlers', () => { undefined, store as never ) - const mounted = await handlers.get('pty:spawn')!(null, { + const spawnArgs = { cols: 80, rows: 24, cwd: '/tmp/persisted-owner', @@ -9094,18 +9096,22 @@ describe('registerPtyHandlers', () => { ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId } - }) + } + + const mounted = await handlers.get('pty:spawn')!(null, spawnArgs) expect(mounted).toMatchObject({ id: 'pty-persisted-owner', - incarnationId: 'inc-persisted-owner', + incarnationId: 'inc-live-owner', isReattach: true }) - expect(providerSpawn).toHaveBeenCalledOnce() - expect(providerSpawn).toHaveBeenCalledWith( + expect(providerSpawn).toHaveBeenNthCalledWith( + 1, expect.objectContaining({ attachOnly: true, sessionId: 'pty-persisted-owner', + expectedIncarnationId: 'inc-stale-owner', + expectedIncarnationIsAuthoritative: false, command: undefined }) ) @@ -9115,10 +9121,50 @@ describe('registerPtyHandlers', () => { ) expect(runtime.noteTerminalSpawnCommand).not.toHaveBeenCalled() expect(store.persistPtyBinding).toHaveBeenCalledOnce() + expect(store.persistPtyBinding).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId, + tabId, + leafId, + ptyId: 'pty-persisted-owner', + incarnationId: 'inc-live-owner', + expectedBinding: { + ptyId: 'pty-persisted-owner', + incarnationId: 'inc-stale-owner' + } + }), + undefined + ) expect( mainWindow.webContents.send.mock.calls.filter(([channel]) => channel === 'pty:spawned') ).toHaveLength(1) expect(runtime.onPtyExit).not.toHaveBeenCalled() + + store.persistPtyBinding.mockClear() + await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow( + 'terminal_pane_owner_changed' + ) + expect(store.persistPtyBinding).not.toHaveBeenCalled() + + runtime.assertPtyRegistrationAllowed.mockImplementationOnce(() => { + throw new Error('agent_session_exited_during_start') + }) + await expect(handlers.get('pty:spawn')!(null, spawnArgs)).rejects.toThrow( + 'agent_session_exited_during_start' + ) + expect(store.persistPtyBinding).not.toHaveBeenCalled() + expect(providerSpawn).toHaveBeenCalledTimes(3) + expect(providerSpawn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + attachOnly: true, + sessionId: 'pty-persisted-owner', + expectedIncarnationId: 'inc-live-owner', + expectedIncarnationIsAuthoritative: true, + command: undefined + }) + ) + clearProviderPtyState('pty-persisted-owner') }) it.each([ @@ -9295,29 +9341,22 @@ describe('registerPtyHandlers', () => { } ) - it.each([ - { label: 'another owner reports it alive', liveness: true }, - { label: 'no owner could answer', liveness: null } - ])('keeps a persisted owner whose absence is unproven ($label)', async ({ liveness }) => { + it('keeps a persisted owner when daemon routing is unresolved', async () => { const worktreeId = 'repo-1::/tmp/unproven-owner' const cwd = '/tmp/unproven-owner' const tabId = 'tab-unproven-owner' const leafId = '56565656-5656-4656-8656-565656565656' const paneKey = makePaneKey(tabId, leafId) - // Why: a degraded router answers unmapped ids from the local fallback, which never - // owned this daemon session — the same "Session not found" a truly dead PTY yields. const providerSpawn = vi.fn( async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { if (options.attachOnly) { - throw new Error('Session not found: pty-unproven-owner') + throw new TerminalSessionOwnerUnverifiedError('pty-unproven-owner') } return { id: 'pty-fresh-unproven', incarnationId: 'inc-fresh-unproven' } } ) - const probePtyLiveness = vi.fn(async () => liveness) setLocalPtyProvider({ spawn: providerSpawn, - probePtyLiveness, write: vi.fn(), resize: vi.fn(), kill: vi.fn(), @@ -9410,7 +9449,6 @@ describe('registerPtyHandlers', () => { }) ).rejects.toThrow('terminal_pane_owner_unverified') - expect(probePtyLiveness).toHaveBeenCalledWith('pty-unproven-owner') // The live PTY keeps its pane binding, gets no synthetic exit, and is not duplicated. expect(providerSpawn).toHaveBeenCalledOnce() expect(providerSpawn.mock.calls[0]?.[0]).toMatchObject({ attachOnly: true }) @@ -9420,11 +9458,7 @@ describe('registerPtyHandlers', () => { expect(session.tabsByWorktree[worktreeId]).toHaveLength(1) }) - // Why the positive direction needs its own case: the sibling retire tests use providers with - // no `probePtyLiveness`, so they skip this guard entirely. Without this, the guard could be - // strengthened into a permanent veto — no daemon-backed pane could ever recover from a dead - // owner — and every suite would stay green. - it('still retires and respawns when a provider proves the owner is absent', async () => { + it('still retires and respawns when the routed provider confirms absence', async () => { const worktreeId = 'repo-1::/tmp/proven-absent-owner' const cwd = '/tmp/proven-absent-owner' const tabId = 'tab-proven-absent-owner' @@ -9438,10 +9472,8 @@ describe('registerPtyHandlers', () => { return { id: 'pty-fresh-proven', incarnationId: 'inc-fresh-proven' } } ) - const probePtyLiveness = vi.fn(async () => false) setLocalPtyProvider({ spawn: providerSpawn, - probePtyLiveness, write: vi.fn(), resize: vi.fn(), kill: vi.fn(), @@ -9532,8 +9564,6 @@ describe('registerPtyHandlers', () => { } }) - expect(probePtyLiveness).toHaveBeenCalledWith('pty-proven-absent-owner') - // Proven absence is the one answer that authorizes retirement, so recovery must proceed. expect(mounted).toMatchObject({ id: 'pty-fresh-proven' }) expect(providerSpawn).toHaveBeenCalledTimes(2) expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ @@ -9548,6 +9578,124 @@ describe('registerPtyHandlers', () => { expect(store.flushOrThrow).toHaveBeenCalledOnce() }) + it('does not poll after the routed provider confirms absence', async () => { + const worktreeId = 'repo-1::/tmp/probe-blip-owner' + const cwd = '/tmp/probe-blip-owner' + const tabId = 'tab-probe-blip-owner' + const leafId = '67676767-6767-4767-8767-676767676767' + const paneKey = makePaneKey(tabId, leafId) + const providerSpawn = vi.fn( + async (options: { attachOnly?: boolean; command?: string; sessionId?: string }) => { + if (options.attachOnly) { + throw new Error('Session not found: pty-probe-blip-owner') + } + return { id: 'pty-fresh-probe-blip', incarnationId: 'inc-fresh-probe-blip' } + } + ) + const probePtyLiveness = vi.fn(async () => null) + setLocalPtyProvider({ + spawn: providerSpawn, + probePtyLiveness, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + let session = { + tabsByWorktree: { + [worktreeId]: [{ id: tabId, worktreeId, ptyId: 'pty-probe-blip-owner' }] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-probe-blip-owner' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-probe-blip-owner' } + } + const store = { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: vi.fn(), + persistPtyBinding: vi.fn(), + getFolderWorkspace: vi.fn(() => undefined), + getFolderWorkspaces: vi.fn(() => []), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + } + const runtime = { + setPtyController: vi.fn(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-probe-blip'), + preAllocateHandleForPty: vi.fn(() => 'term-probe-blip'), + registerPreAllocatedHandleForPty: vi.fn(), + beginPtyRegistration: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + assertPtyRegistrationAllowed: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedHeadlessTerminal: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + store as never + ) + + const mounted = await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd, + command: 'codex resume probe-blip-session', + worktreeId, + tabId, + leafId, + env: { + ORCA_PANE_KEY: paneKey, + ORCA_TAB_ID: tabId, + ORCA_WORKTREE_ID: worktreeId + } + }) + + expect(probePtyLiveness).not.toHaveBeenCalled() + expect(mounted).toMatchObject({ id: 'pty-fresh-probe-blip' }) + expect(providerSpawn).toHaveBeenCalledTimes(2) + expect(runtime.onPtyExit).toHaveBeenCalledWith( + 'pty-probe-blip-owner', + 0, + 'inc-probe-blip-owner' + ) + }) + // Why: a parked pane (stopped with keepHistory) leaves the runtime holding the binding while // persistence has already dropped it. Reading "nothing left to retire" as a competing owner // aborted materialization *after* signalling the exit, which destroyed the pane instead of @@ -9676,7 +9824,7 @@ describe('registerPtyHandlers', () => { } }) - expect(probePtyLiveness).toHaveBeenCalledWith('pty-already-retired-owner') + expect(probePtyLiveness).not.toHaveBeenCalled() expect(mounted).toMatchObject({ id: 'pty-fresh-already-retired' }) expect(providerSpawn).toHaveBeenCalledTimes(2) expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index e4f50a62501..5d858cac7f3 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -93,6 +93,7 @@ import { import { resolveWslSessionContext } from '../daemon/wsl-session-context' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' +import { TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' @@ -599,6 +600,9 @@ type StablePaneOwner = { leafId: string ptyId: string incarnationId?: string + hasPersistedBinding?: true + persistedIncarnationId?: string + runtimeIncarnationId?: string } type StablePaneAdoption = { result: PtySpawnResult @@ -677,13 +681,6 @@ function resolveStablePaneOwner( throw new Error('terminal_pane_owner_host_mismatch') } const runtimeIncarnationId = ptyIncarnationById.get(ptyId) - if ( - runtimeIncarnationId && - persisted?.incarnationId && - runtimeIncarnationId !== persisted.incarnationId - ) { - throw new Error('terminal_pane_owner_conflict') - } const parsed = parsePaneKey(paneKey) if (!parsed) { return null @@ -695,7 +692,10 @@ function resolveStablePaneOwner( ptyId, ...(runtimeIncarnationId || persisted?.incarnationId ? { incarnationId: runtimeIncarnationId ?? persisted?.incarnationId } - : {}) + : {}), + ...(persisted ? { hasPersistedBinding: true as const } : {}), + ...(persisted?.incarnationId ? { persistedIncarnationId: persisted.incarnationId } : {}), + ...(runtimeIncarnationId ? { runtimeIncarnationId } : {}) } } @@ -717,7 +717,7 @@ function retirePersistedStablePaneOwner( // not a competing owner. Reporting failure here strands the pane after its PTY is proven dead. return true } - if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.incarnationId) { + if (current.ptyId !== owner.ptyId || current.incarnationId !== owner.persistedIncarnationId) { return false } const session = store.getWorkspaceSession(hostId) @@ -726,7 +726,7 @@ function retirePersistedStablePaneOwner( parentTabId: owner.tabId, leafId: owner.leafId, ptyId: owner.ptyId, - ...(owner.incarnationId ? { incarnationId: owner.incarnationId } : {}) + ...(current.incarnationId ? { incarnationId: current.incarnationId } : {}) }) if (retired === session) { return false @@ -748,6 +748,47 @@ type StablePaneSpawnContext = { onFreshSpawn?: (result: PtySpawnResult) => void } +function stablePanePersistenceFence( + owner: StablePaneOwner | null +): { ptyId: string; incarnationId?: string } | undefined { + return owner?.hasPersistedBinding + ? { + ptyId: owner.ptyId, + ...(owner.persistedIncarnationId ? { incarnationId: owner.persistedIncarnationId } : {}) + } + : undefined +} + +function persistAdmittedStablePaneBinding(args: { + store: Store | undefined + owner: StablePaneOwner | null + result: PtySpawnResult + worktreeId: string | undefined + startupCwd: string | undefined + connectionId: string | null | undefined +}): boolean { + const expectedBinding = stablePanePersistenceFence(args.owner) + if (!args.store || !args.owner || !args.worktreeId || !expectedBinding) { + return false + } + const persisted = args.store.persistPtyBinding( + { + worktreeId: args.worktreeId, + tabId: args.owner.tabId, + leafId: args.owner.leafId, + ptyId: args.result.id, + ...(args.result.incarnationId ? { incarnationId: args.result.incarnationId } : {}), + ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + expectedBinding + }, + args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined + ) + if (persisted === false) { + throw new Error('terminal_pane_owner_changed') + } + return true +} + async function attachStablePaneOwner( args: StablePaneSpawnContext & { owner: StablePaneOwner } ): Promise<{ result: PtySpawnResult; owner: StablePaneOwner } | null> { @@ -758,6 +799,8 @@ async function attachStablePaneOwner( ...spawnOptions, sessionId: owner.ptyId, attachOnly: true, + expectedIncarnationId: owner.runtimeIncarnationId ?? owner.persistedIncarnationId, + expectedIncarnationIsAuthoritative: owner.runtimeIncarnationId !== undefined, isNewSession: undefined, command: undefined, commandDelivery: undefined, @@ -769,25 +812,19 @@ async function attachStablePaneOwner( onPtySpawnCommitted: undefined }) } catch (error) { + if (error instanceof TerminalSessionOwnerUnverifiedError) { + throw new Error('terminal_pane_owner_unverified') + } if (!isPtyAlreadyGoneError(error)) { throw error } - // Why: "Session not found" only proves the provider we asked has no such PTY — and a - // degraded router answers unmapped ids from the local fallback, which never owned a - // daemon session. Retiring on that would signal exit and delete a live agent's pane - // binding. Absence must be proven across every possible owner first; `null` (nobody - // could answer) is not absence. Providers without a probe are their own sole owner, - // so their refusal stays authoritative. - if (provider.probePtyLiveness && (await provider.probePtyLiveness(owner.ptyId)) !== false) { - throw new Error('terminal_pane_owner_unverified') - } const ownerBeforeRetire = args.resolveOwner?.() if ( ownerBeforeRetire && (ownerBeforeRetire.ptyId !== owner.ptyId || - (ownerBeforeRetire.incarnationId !== undefined && - owner.incarnationId !== undefined && - ownerBeforeRetire.incarnationId !== owner.incarnationId)) + ownerBeforeRetire.runtimeIncarnationId !== owner.runtimeIncarnationId || + ownerBeforeRetire.hasPersistedBinding !== owner.hasPersistedBinding || + ownerBeforeRetire.persistedIncarnationId !== owner.persistedIncarnationId) ) { throw new Error('terminal_pane_owner_changed') } @@ -808,7 +845,9 @@ async function attachStablePaneOwner( if ( result.id !== owner.ptyId || result.isReattach !== true || - (owner.incarnationId !== undefined && result.incarnationId !== owner.incarnationId) + (owner.runtimeIncarnationId !== undefined && + result.incarnationId !== owner.runtimeIncarnationId) || + (result.incarnationId === undefined && owner.incarnationId !== undefined) ) { throw new Error('terminal_pane_owner_changed') } @@ -4680,6 +4719,7 @@ export function registerPtyHandlers( : null let result: PtySpawnResult let stablePaneOwner: StablePaneOwner | null = null + let stablePaneBindingPersisted = false let rejectedRegistrationCandidate: PtySpawnResult | null = null let pendingRegistrationPtyId: string | null = null let preparedProvisionalExecutionContext = false @@ -4902,6 +4942,24 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.delete(args.preAllocatedHandle) } } + try { + stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store: hostSessionBinding?.store, + owner: stablePaneOwner, + result, + worktreeId: hostSessionBinding?.worktreeId, + startupCwd: cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist runtime PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } if (result.agentSessionEnsure?.disposition === 'adopted') { const owner = result.agentSessionEnsure.owner ptyOwnership.set(result.id, args.connectionId ?? ptyOwnership.get(result.id) ?? null) @@ -4969,7 +5027,7 @@ export function registerPtyHandlers( target: codexSelectionTarget, settings: getSettings?.() }) - if (hostSessionBinding) { + if (hostSessionBinding && !stablePaneBindingPersisted) { try { const binding = { worktreeId: hostSessionBinding.worktreeId, @@ -5625,6 +5683,7 @@ export function registerPtyHandlers( let finishTerminalInstall = (): void => {} let result: PtySpawnResult let stablePaneOwner: StablePaneOwner | null = null + let stablePaneBindingPersisted = false let rejectedRegistrationCandidate: PtySpawnResult | null = null let pendingRegistrationPtyId: string | null = null let preparedProvisionalExecutionContext = false @@ -6245,6 +6304,24 @@ export function registerPtyHandlers( trustedTerminalHandleEnv.delete(preAllocatedHandle) } } + try { + stablePaneBindingPersisted = persistAdmittedStablePaneBinding({ + store, + owner: stablePaneOwner, + result, + worktreeId: args.worktreeId, + startupCwd: cwd, + connectionId: args.connectionId + }) + } catch (error) { + if (error instanceof Error && error.message === 'terminal_pane_owner_changed') { + throw error + } + console.error('[pty] failed to persist PTY binding after attach:', error) + throw Object.assign(new Error(createTerminalSessionStateSaveFailureMessage()), { + agentSessionOperationOutcome: 'unknown' as const + }) + } spawnTiming.log(result.id, { daemon: isDaemonHostSpawn, reattach: result.isReattach ?? false @@ -6303,7 +6380,8 @@ export function registerPtyHandlers( store && typeof args.worktreeId === 'string' && typeof args.tabId === 'string' && - validatedLeafId !== null + validatedLeafId !== null && + !stablePaneBindingPersisted ) { try { const binding = { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 24cc6e512cc..0a48bbbb343 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -9033,6 +9033,221 @@ describe('Store', () => { ).toBeUndefined() }) + it('reconciles only the incarnation of an unchanged durable PTY binding', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + }) + + expect( + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }) + ).toBe(true) + + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe('inc-live') + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-live' + ) + }) + + it('rejects competing PTY and incarnation changes during reconciliation', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-current' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-current' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-current' } + }) + + for (const competing of [ + { ptyId: 'pty-replaced', expectedIncarnationId: 'inc-current' }, + { ptyId: 'pty-current', expectedIncarnationId: 'inc-replaced' } + ]) { + expect( + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: competing.ptyId, + incarnationId: 'inc-live', + expectedBinding: { + ptyId: competing.ptyId, + incarnationId: competing.expectedIncarnationId + } + }) + ).toBe(false) + } + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-current' + ) + }) + + it('reconciles only the requested execution-host partition', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + const session = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf' as const, leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + } + store.setWorkspaceSession(structuredClone(session), 'local') + store.setWorkspaceSession(structuredClone(session), 'ssh:ssh-1') + + expect( + store.persistPtyBinding( + { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-remote-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }, + 'ssh:ssh-1' + ) + ).toBe(true) + + expect(store.getWorkspaceSession('local').terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-stale' + ) + expect(store.getWorkspaceSession('ssh:ssh-1').terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-remote-live' + ) + }) + + it.each([ + { label: 'local', hostId: undefined }, + { label: 'SSH', hostId: 'ssh:ssh-1' } + ])( + 'preserves a reconciled incarnation across a renderer snapshot ($label)', + async ({ hostId }) => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + }, + hostId + ) + + expect( + store.persistPtyBinding( + { + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }, + hostId + ) + ).toBe(true) + + const rendererSnapshot = structuredClone(store.getWorkspaceSession(hostId)) + delete rendererSnapshot.terminalPtyIncarnationsByPaneKey + delete rendererSnapshot.terminalTopologyRevisionByRepoId + store.setWorkspaceSession(rendererSnapshot, hostId) + + expect(store.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-live' + ) + const reloaded = await createStore() + expect(reloaded.getWorkspaceSession(hostId).terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-live' + ) + } + ) + + it('rolls back incarnation reconciliation when the durability barrier fails', async () => { + const store = await createStore() + const paneKey = `tab1:${TEST_LEAF_1}` + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + wt1: [makeTerminalTab({ id: 'tab1', worktreeId: 'wt1', ptyId: 'pty-1' })] + }, + terminalLayoutsByTabId: { + tab1: { + root: { type: 'leaf', leafId: TEST_LEAF_1 }, + activeLeafId: TEST_LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-stale' } + }) + vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + throw new Error('disk full') + }) + + expect(() => + store.persistPtyBinding({ + worktreeId: 'wt1', + tabId: 'tab1', + leafId: TEST_LEAF_1, + ptyId: 'pty-1', + incarnationId: 'inc-live', + expectedBinding: { ptyId: 'pty-1', incarnationId: 'inc-stale' } + }) + ).toThrow('disk full') + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey?.[paneKey]).toBe( + 'inc-stale' + ) + }) + it('adds a missing split leaf to the durable root when a new pane spawns before layout debounce', async () => { const store = await createStore() store.setWorkspaceSession({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index af82bcbdd89..be3f10fd78f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -6621,11 +6621,26 @@ export class Store { ptyId: string incarnationId?: string startupCwd?: string + expectedBinding?: { ptyId: string; incarnationId?: string } }, hostId?: string | null - ): void { + ): boolean { const resolvedHostId = this.resolveHostId(hostId) const session = this.getWorkspaceSession(resolvedHostId) + const paneKey = `${args.tabId}:${args.leafId}` + if (args.expectedBinding) { + const tab = session.tabsByWorktree?.[args.worktreeId]?.find( + (candidate) => candidate.id === args.tabId && candidate.worktreeId === args.worktreeId + ) + const boundPtyId = session.terminalLayoutsByTabId?.[args.tabId]?.ptyIdsByLeafId?.[args.leafId] + if ( + !tab || + boundPtyId !== args.expectedBinding.ptyId || + session.terminalPtyIncarnationsByPaneKey?.[paneKey] !== args.expectedBinding.incarnationId + ) { + return false + } + } if (resolvedHostId !== LOCAL_EXECUTION_HOST_ID) { this.state.workspaceSessionsByHostId = { ...this.state.workspaceSessionsByHostId, @@ -6633,15 +6648,17 @@ export class Store { } } const sessionBeforeBinding = cloneWorkspaceSessionState(session) - const paneKey = `${args.tabId}:${args.leafId}` + const reconciledIncarnation = + args.expectedBinding !== undefined && + args.incarnationId !== args.expectedBinding.incarnationId let terminalMembershipChanged = false - const advanceTopologyAfterMembershipChange = (): void => { + const advanceTopologyFence = (): void => { const repoId = getRepoIdFromWorktreeId(args.worktreeId) const currentRevision = session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0 - if (!terminalMembershipChanged || currentRevision <= 0) { + if (!reconciledIncarnation && (!terminalMembershipChanged || currentRevision <= 0)) { return } - // Why: a real host-admitted spawn after a retirement must be distinguishable from a stale renderer replay. + // Why: host-admitted membership or incarnation changes must outrank a stale renderer replay. session.terminalTopologyRevisionByRepoId = { ...session.terminalTopologyRevisionByRepoId, [repoId]: currentRevision + 1 @@ -6696,14 +6713,14 @@ export class Store { } if (!isTerminalLeafId(args.leafId)) { // Why: keep legacy renderer-local pane ids out of durable leaf-keyed layout state after the UUID migration. - advanceTopologyAfterMembershipChange() + advanceTopologyFence() try { this.flushOrThrow() } catch (err) { restoreSession() throw err } - return + return true } const layout = session.terminalLayoutsByTabId?.[args.tabId] if (layout) { @@ -6744,13 +6761,14 @@ export class Store { } } } - advanceTopologyAfterMembershipChange() + advanceTopologyFence() try { this.flushOrThrow() } catch (err) { restoreSession() throw err } + return true } // ── SSH Targets ──────────────────────────────────────────────────── diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index d43158482c0..64dd9850930 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -38,6 +38,7 @@ import type { ShellReadySignal } from './local-pty-shell-ready' import { removeInheritedNoColor } from '../pty/terminal-color-env' import { removeAppImageRuntimeEnv } from '../pty/appimage-terminal-env' import { stripInheritedBuildModeEnv } from '../pty/build-mode-env' +import { SessionNotFoundError } from '../daemon/daemon-errors' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { addWslEnvKeys } from '../wsl-env' @@ -546,7 +547,7 @@ export class LocalPtyProvider implements IPtyProvider { } } if (args.attachOnly) { - throw new Error(`Session not found: ${args.sessionId ?? ''}`) + throw new SessionNotFoundError(args.sessionId ?? '') } const id = allocatePtyId(reattachId ?? undefined) const incarnationId = randomUUID() diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index b766b11497d..98ab3d9b69b 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -64,6 +64,10 @@ export type PtySpawnOptions = { isNewSession?: boolean /** Attach the named session atomically or fail without creating a process. */ attachOnly?: boolean + /** Exact persisted owner expected by an attach-only routing decision. */ + expectedIncarnationId?: PtyIncarnationId + /** True when runtime state makes the expected incarnation a hard attach fence. */ + expectedIncarnationIsAuthoritative?: boolean /** Why: allows the renderer to request a specific shell for a single new * terminal tab (e.g. "open this tab in WSL" from the "+" submenu) without * changing the user's persistent default shell setting. Only consulted on diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts index 56e7ffd0afc..bd9aedd375c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + humanizeTerminalError, isSshReconnectOwnedTerminalError, shouldOfferDaemonRestart, stripSshReconnectOwnedErrorLines @@ -36,6 +37,24 @@ describe('isSshReconnectOwnedTerminalError', () => { }) }) +describe('humanizeTerminalError', () => { + it('replaces the pane-owner-unverified code with actionable copy', () => { + const humanized = humanizeTerminalError('terminal_pane_owner_unverified') + expect(humanized).not.toContain('terminal_pane_owner_unverified') + expect(humanized).toContain('Reopen this pane to retry') + }) + + it('humanizes an IPC-wrapped pane-owner-unverified error', () => { + const wrapped = + "Error invoking remote method 'pty:spawn': Error: terminal_pane_owner_unverified" + expect(humanizeTerminalError(wrapped)).not.toContain('terminal_pane_owner_unverified') + }) + + it('leaves other errors untouched', () => { + expect(humanizeTerminalError('Paste failed.')).toBe('Paste failed.') + }) +}) + describe('stripSshReconnectOwnedErrorLines', () => { it('clears an error that is only SSH reconnect text', () => { expect(stripSshReconnectOwnedErrorLines(SSH_FAILURE)).toBeNull() diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index 7f38733fa49..99a00e54f9a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -12,6 +12,8 @@ const STALE_DAEMON_CWD_MARKERS = [ "Daemon's working directory is gone", 'node-pty: daemon_cwd failed: ENOENT' ] +// Thrown by ipc/pty.ts when a persisted pane owner can't be proven alive or dead (STA-3536). +const PANE_OWNER_UNVERIFIED_MARKER = 'terminal_pane_owner_unverified' function isSshError(error: string): boolean { return error.startsWith(SSH_PREFIX) || error.includes(SSH_RELAY_LOST_MARKER) @@ -42,6 +44,20 @@ export function shouldOfferDaemonRestart(error: string): boolean { ) } +/** Swaps the raw pane-owner-unverified code for copy a user can act on. */ +export function humanizeTerminalError(error: string): string { + if (!error.includes(PANE_OWNER_UNVERIFIED_MARKER)) { + return error + } + return error.replace( + PANE_OWNER_UNVERIFIED_MARKER, + translate( + 'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db', + "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry." + ) + ) +} + export function TerminalErrorToast({ error, onDismiss, @@ -53,6 +69,7 @@ export function TerminalErrorToast({ }): React.JSX.Element { const ssh = isSshError(error) const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error) + const displayError = humanizeTerminalError(error) return (
- {error} + {displayError} {showDaemonRestart ? ( <> {'\n'} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3f940076736..3792f691513 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2801,7 +2801,8 @@ "e4aa243f8c": "Restart daemon", "a7e2fd2699": "file an issue", "5c8ce20be6": "If this persists, please", - "cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state." + "cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state.", + "7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry." }, "TerminalPane": { "ac112e9036": "Remove title", From b5218374819973c26187c7c5c7e76048028835f5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:06:15 -0700 Subject: [PATCH 26/46] fix(mobile): keep hosts visible when credentials are unavailable (#12775) * fix(mobile): keep hosts visible when credentials are unavailable * fix(mobile): guard unavailable host recovery * fix(mobile): protect replacement credential writes * fix(mobile): retain superseded cleanup intents * fix(mobile): preserve credential cleanup authority * fix(mobile): make host cleanup crash-safe --- mobile/app/_layout.tsx | 9 +- mobile/app/index.tsx | 62 ++- mobile/src/components/MobileHostCard.test.tsx | 28 +- mobile/src/components/MobileHostCard.tsx | 46 ++- .../notification-routing.test.ts | 34 +- .../src/notifications/notification-routing.ts | 20 +- .../use-open-notification-route.ts | 10 +- .../transport/host-catalog-credential-join.ts | 52 +++ .../transport/host-catalog-selection.test.ts | 39 ++ .../src/transport/host-catalog-selection.ts | 7 + .../transport/host-credential-cleanup.test.ts | 20 + .../src/transport/host-credential-cleanup.ts | 11 +- .../host-credential-write-revision.ts | 21 + .../src/transport/host-device-token-store.ts | 43 ++ .../src/transport/host-list-load-sharing.ts | 13 +- mobile/src/transport/host-metadata-store.ts | 51 +++ mobile/src/transport/host-store.test.ts | 331 +++++++++++++++- mobile/src/transport/host-store.ts | 370 +++++++++--------- .../mobile-relay-credential-bundle.ts | 2 + .../mobile-relay-direct-upgrade-journal.ts | 2 + mobile/src/transport/types.ts | 7 + .../unpaired-host-credential-deletion.ts | 58 +++ 22 files changed, 1010 insertions(+), 226 deletions(-) create mode 100644 mobile/src/transport/host-catalog-credential-join.ts create mode 100644 mobile/src/transport/host-catalog-selection.test.ts create mode 100644 mobile/src/transport/host-catalog-selection.ts create mode 100644 mobile/src/transport/host-credential-write-revision.ts create mode 100644 mobile/src/transport/host-device-token-store.ts create mode 100644 mobile/src/transport/host-metadata-store.ts create mode 100644 mobile/src/transport/unpaired-host-credential-deletion.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 1292b6e5f5f..9080cdedcf9 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -10,7 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationTarget } from '../src/notifications/notification-routing' import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route' -import { loadHosts } from '../src/transport/host-store' +import { loadHostCatalog } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' @@ -95,9 +95,12 @@ export default function RootLayout() { } async function getNavigationTarget(data: unknown) { - const hosts = await loadHosts().catch(() => null) + const hosts = await loadHostCatalog().catch(() => null) return getNotificationNavigationTarget(data, { - knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined + knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined, + credentialStatusByHostId: hosts + ? new Map(hosts.map((host) => [host.id, host.credentialStatus])) + : undefined }) } diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 642cbfac539..1a4681565e3 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -15,7 +15,8 @@ import { UsageBar } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' -import { loadHosts } from '../src/transport/host-store' +import { loadHostCatalog } from '../src/transport/host-store' +import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection' import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch' @@ -32,7 +33,7 @@ import { loadMobileOnboardingSteps, mobileOnboardingDestination } from '../src/onboarding/mobile-onboarding-plan' -import type { ConnectionState, HostProfile } from '../src/transport/types' +import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' import { OrcaLogo } from '../src/components/OrcaLogo' import { MobileHostCard } from '../src/components/MobileHostCard' @@ -224,9 +225,9 @@ export default function HomeScreen() { const insets = useSafeAreaInsets() // Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad. const { isWideLayout, contentMaxWidth } = useResponsiveLayout() - const [hosts, setHosts] = useState([]) + const [hostCatalog, setHostCatalog] = useState([]) const [actionTarget, setActionTarget] = useState(null) - const [confirmRemove, setConfirmRemove] = useState(null) + const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null) const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) const [hostLastConnected, setHostLastConnected] = useState>({}) @@ -242,6 +243,7 @@ export default function HomeScreen() { const onboardingOptInCheckedRef = useRef(false) // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. + const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog]) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) @@ -308,12 +310,12 @@ export default function HomeScreen() { useFocusEffect( useCallback(() => { let stale = false - void loadHosts().then(async (h) => { + void loadHostCatalog().then(async (catalog) => { if (stale) { return } - setHosts(h) - if (h.length === 0 || onboardingOptInCheckedRef.current) { + setHostCatalog(catalog) + if (catalog.length === 0 || onboardingOptInCheckedRef.current) { return } onboardingOptInCheckedRef.current = true @@ -351,6 +353,10 @@ export default function HomeScreen() { () => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected), [hosts] ) + const sortedHostCatalog = useMemo( + () => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected), + [hostCatalog] + ) // Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working. useEffect(() => { @@ -389,17 +395,24 @@ export default function HomeScreen() { } } // Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'. - for (const host of hosts) { + for (const host of hostCatalog) { if (liveIds.has(host.id)) { continue } - if (!host.publicKeyB64 || !host.deviceToken) { + if (host.credentialStatus === 'missing') { if (next[host.id] !== 'auth-failed') { next[host.id] = 'auth-failed' changed = true } continue } + if (host.credentialStatus === 'temporarily-unavailable') { + if (next[host.id] !== 'disconnected') { + next[host.id] = 'disconnected' + changed = true + } + continue + } const prevState = next[host.id] if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') { next[host.id] = 'disconnected' @@ -408,14 +421,14 @@ export default function HomeScreen() { } // Drop entries for hosts we no longer track at all. for (const id of Object.keys(next)) { - if (!liveIds.has(id) && hosts.some((h) => h.id === id) === false) { + if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) { delete next[id] changed = true } } return changed ? next : prev }) - }, [allClients, hosts]) + }, [allClients, hostCatalog]) // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the @@ -629,7 +642,7 @@ export default function HomeScreen() { try { await removeHostAndCloseClient(hostToRemove.id, closeHostClient) setConfirmRemove(null) - setHosts(await loadHosts()) + setHostCatalog(await loadHostCatalog()) } catch { // Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent. setConfirmRemove(hostToRemove) @@ -655,7 +668,7 @@ export default function HomeScreen() { - {hosts.length === 0 ? ( + {hostCatalog.length === 0 ? ( /* ─── Empty state: onboarding ─── */ h.id} // Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator. contentContainerStyle={[ @@ -744,14 +757,29 @@ export default function HomeScreen() { return ( router.push(`/h/${item.id}`)} + onPress={() => { + if (item.credentialStatus === 'missing') { + router.push('/pair-scan') + } else if (item.credentialStatus === 'temporarily-unavailable') { + void loadHostCatalog() + .then(setHostCatalog) + .catch(() => Alert.alert('Could not check pairing', 'Please try again.')) + } else { + router.push(`/h/${item.id}`) + } + }} onLongPress={() => { triggerMediumImpact() - setActionTarget(item) + if (item.profile) { + setActionTarget(item.profile) + } else { + setConfirmRemove(item) + } }} /> ) @@ -928,7 +956,7 @@ export default function HomeScreen() { onReconnect: (hostId) => void forceReconnectHost(hostId), onDisconnect: closeHostClient, onEdit: openMobileHostEdit, - onRemove: setConfirmRemove + onRemove: (host) => setConfirmRemove(host) })} onClose={() => setActionTarget(null)} /> diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx index 6138e311127..5a6855f71dc 100644 --- a/mobile/src/components/MobileHostCard.test.tsx +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectionVerdict } from '../transport/connection-health' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' -import type { ConnectionState, HostProfile } from '../transport/types' +import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types' import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo @@ -54,6 +54,7 @@ describe('MobileHostCard', () => { state?: ConnectionState verdict?: ConnectionVerdict path?: MobileConnectionPath + credentialStatus?: HostCredentialStatus } ): Promise { await act(async () => { @@ -63,6 +64,7 @@ describe('MobileHostCard', () => { state: overrides?.state ?? 'connected', verdict: overrides?.verdict ?? verdict, path: overrides?.path ?? 'lan', + credentialStatus: overrides?.credentialStatus, worktreeInfo, onPress: () => {}, onLongPress: () => {} @@ -139,4 +141,28 @@ describe('MobileHostCard', () => { expect(lines).not.toContain('0 worktrees') expect(lines).not.toContain('Worktree list unavailable') }) + + it('offers re-pairing when the credential is missing', async () => { + const lines = await renderCard(loaded, { + state: 'connected', + verdict: { kind: 'auth-failed', label: 'Pairing invalid' }, + credentialStatus: 'missing' + }) + + expect(lines).toContain('Pairing invalid') + expect(lines).toContain('Tap to re-pair with your desktop') + expect(lines).not.toContain('12 worktrees · 2 active') + }) + + it('offers a retry without declaring a transient read failure invalid', async () => { + const lines = await renderCard(undefined, { + state: 'disconnected', + verdict: { kind: 'normal', label: 'Disconnected' }, + credentialStatus: 'temporarily-unavailable' + }) + + expect(lines).toContain('Pairing temporarily unavailable') + expect(lines).toContain('Unlock your phone, then tap to retry') + expect(lines).not.toContain('Pairing invalid') + }) }) diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index f81f936405a..f52f917e22a 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -4,13 +4,14 @@ import type { ConnectionVerdict } from '../transport/connection-health' import { verdictDisplayLabel } from '../transport/connection-health' import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' -import type { ConnectionState, HostProfile } from '../transport/types' +import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types' import { colors, radii, spacing } from '../theme/mobile-theme' import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info' import { StatusDot } from './StatusDot' export function MobileHostCard(props: { - host: HostProfile + host: HostProfile | HostCatalogEntry + credentialStatus?: HostCatalogEntry['credentialStatus'] state: ConnectionState verdict: ConnectionVerdict path: MobileConnectionPath @@ -20,13 +21,26 @@ export function MobileHostCard(props: { onPress: () => void onLongPress: () => void }) { - const connected = props.state === 'connected' + const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable' + const credentialMissing = props.credentialStatus === 'missing' + const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing // Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the // path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is // named — 'lan' doubles as the unknown-path default, so it would be a guess before connect. const dialingPath = ['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay' - const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) + const isError = + credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) + const statusLabel = credentialMissing + ? 'Pairing invalid' + : credentialUnavailable + ? 'Pairing temporarily unavailable' + : verdictDisplayLabel(props.verdict) + const statusVerdict: ConnectionVerdict = credentialMissing + ? { kind: 'auth-failed', label: statusLabel } + : credentialUnavailable + ? { kind: 'warning', label: statusLabel } + : props.verdict const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) return ( - - - {verdictDisplayLabel(props.verdict)} - {connected || dialingPath ? ` · ${mobileConnectionPathLabel(props.path)}` : ''} + + + {statusLabel} + {!credentialMissing && !credentialUnavailable && (connected || dialingPath) + ? ` · ${mobileConnectionPathLabel(props.path)}` + : ''} {connected && worktreeSummary ? ( @@ -62,6 +85,13 @@ export function MobileHostCard(props: { Update desktop Orca and sign in to connect from anywhere ) : null} + {credentialMissing || credentialUnavailable ? ( + + {credentialMissing + ? 'Tap to re-pair with your desktop' + : 'Unlock your phone, then tap to retry'} + + ) : null} diff --git a/mobile/src/notifications/notification-routing.test.ts b/mobile/src/notifications/notification-routing.test.ts index 6b5762aefb4..779aa2425e5 100644 --- a/mobile/src/notifications/notification-routing.test.ts +++ b/mobile/src/notifications/notification-routing.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildLocalNotificationData, getNotificationNavigationTarget } from './notification-routing' +import { + buildLocalNotificationData, + getNotificationNavigationTarget, + notificationCredentialRecoveryRoute +} from './notification-routing' describe('notification routing', () => { it('includes the host id in locally scheduled notification data', () => { @@ -55,4 +59,32 @@ describe('notification routing', () => { ) ).toBeNull() }) + + it.each([ + ['missing', 're-pair'], + ['temporarily-unavailable', 'retry'] + ] as const)('routes %s host credentials to %s recovery', (status, recovery) => { + const target = getNotificationNavigationTarget( + { hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' }, + { + knownHostIds: new Set(['host-1']), + credentialStatusByHostId: new Map([['host-1', status]]) + } + ) + + expect(target).toMatchObject({ hostId: 'host-1', credentialRecovery: recovery }) + expect(notificationCredentialRecoveryRoute(target!)).toBe( + status === 'missing' ? '/pair-scan' : '/' + ) + }) + + it('keeps ready hosts on the requested notification destination', () => { + const target = getNotificationNavigationTarget( + { hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' }, + { credentialStatusByHostId: new Map([['host-1', 'ready']]) } + ) + + expect(target?.sessionTarget).not.toBeNull() + expect(notificationCredentialRecoveryRoute(target!)).toBeNull() + }) }) diff --git a/mobile/src/notifications/notification-routing.ts b/mobile/src/notifications/notification-routing.ts index feda52c596b..5f81fb3567d 100644 --- a/mobile/src/notifications/notification-routing.ts +++ b/mobile/src/notifications/notification-routing.ts @@ -1,5 +1,6 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' import { mobileSessionRouteTarget } from '../session/mobile-session-route' +import type { HostCredentialStatus } from '../transport/types' export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test' @@ -18,6 +19,7 @@ export type LocalNotificationData = { export type NotificationNavigationOptions = { knownHostIds?: ReadonlySet + credentialStatusByHostId?: ReadonlyMap } function readNonEmptyString(value: unknown): string | null { @@ -46,8 +48,18 @@ export function buildLocalNotificationData( export type NotificationNavigationTarget = Readonly<{ hostId: string sessionTarget: HostStackRouteTarget | null + credentialRecovery?: 'retry' | 're-pair' }> +export function notificationCredentialRecoveryRoute( + target: NotificationNavigationTarget +): '/' | '/pair-scan' | null { + if (target.credentialRecovery === 're-pair') { + return '/pair-scan' + } + return target.credentialRecovery === 'retry' ? '/' : null +} + export function getNotificationNavigationTarget( data: unknown, options: NotificationNavigationOptions = {} @@ -66,8 +78,14 @@ export function getNotificationNavigationTarget( } const worktreeId = readNonEmptyString(record.worktreeId) + const credentialStatus = options.credentialStatusByHostId?.get(hostId) return { hostId, - sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null + sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null, + ...(credentialStatus === 'missing' + ? { credentialRecovery: 're-pair' as const } + : credentialStatus === 'temporarily-unavailable' + ? { credentialRecovery: 'retry' as const } + : {}) } } diff --git a/mobile/src/notifications/use-open-notification-route.ts b/mobile/src/notifications/use-open-notification-route.ts index 3a0809c7be8..31d4715c43e 100644 --- a/mobile/src/notifications/use-open-notification-route.ts +++ b/mobile/src/notifications/use-open-notification-route.ts @@ -2,7 +2,10 @@ import { useCallback } from 'react' import { useRouter } from 'expo-router' import { hostStackHostRoute } from '../navigation/host-stack-navigation' import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' -import type { NotificationNavigationTarget } from './notification-routing' +import { + notificationCredentialRecoveryRoute, + type NotificationNavigationTarget +} from './notification-routing' export function useOpenNotificationRoute(): (target: NotificationNavigationTarget) => void { const openHostStackRoute = useOpenHostStackRoute() @@ -10,6 +13,11 @@ export function useOpenNotificationRoute(): (target: NotificationNavigationTarge return useCallback( (target) => { + const recoveryRoute = notificationCredentialRecoveryRoute(target) + if (recoveryRoute) { + router.push(recoveryRoute) + return + } if (target.sessionTarget) { openHostStackRoute(target.hostId, target.sessionTarget) return diff --git a/mobile/src/transport/host-catalog-credential-join.ts b/mobile/src/transport/host-catalog-credential-join.ts new file mode 100644 index 00000000000..e914742ff4e --- /dev/null +++ b/mobile/src/transport/host-catalog-credential-join.ts @@ -0,0 +1,52 @@ +import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' +import type { HostListSnapshot } from './host-list-load-sharing' +import type { HostCatalogEntry, StoredHostProfile } from './types' + +export async function joinHostCatalogCredentials(args: { + storedHosts: readonly StoredHostProfile[] + overlays: ReadonlyMap + tokenCache: Map + readToken: (hostId: string) => Promise + getRevision: () => number +}): Promise { + const catalog: HostCatalogEntry[] = [] + const profiles: HostListSnapshot['profiles'] = [] + for (const stored of args.storedHosts) { + let token = args.tokenCache.get(stored.id) + let credentialStatus: HostCatalogEntry['credentialStatus'] = 'ready' + if (!token) { + const readRevision = args.getRevision() + let fetched: string | null + try { + fetched = await args.readToken(stored.id) + } catch { + credentialStatus = 'temporarily-unavailable' + fetched = null + } + if (!fetched && credentialStatus === 'ready') { + credentialStatus = 'missing' + } + token = fetched ?? undefined + if (token && readRevision === args.getRevision()) { + args.tokenCache.set(stored.id, token) + } + } + const overlay = args.overlays.get(stored.id) + const base = { + ...stored, + ...(overlay + ? { + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + } + : {}) + } + const profile = token ? { ...base, deviceToken: token } : null + catalog.push({ ...base, credentialStatus, profile }) + if (profile) { + profiles.push(profile) + } + } + return { catalog, profiles } +} diff --git a/mobile/src/transport/host-catalog-selection.test.ts b/mobile/src/transport/host-catalog-selection.test.ts new file mode 100644 index 00000000000..5f5d85ca787 --- /dev/null +++ b/mobile/src/transport/host-catalog-selection.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { selectConnectableHostProfiles } from './host-catalog-selection' +import type { HostCatalogEntry, HostProfile } from './types' + +const profile: HostProfile = { + id: 'host-ready', + name: 'Ready', + endpoint: 'ws://127.0.0.1:1', + deviceToken: 'token', + publicKeyB64: 'key-ready', + lastConnected: 0 +} + +function unavailable( + id: string, + credentialStatus: 'temporarily-unavailable' | 'missing' +): HostCatalogEntry { + return { + id, + name: id, + endpoint: `ws://127.0.0.1/${id}`, + publicKeyB64: `key-${id}`, + lastConnected: 0, + credentialStatus, + profile: null + } +} + +describe('selectConnectableHostProfiles', () => { + it('excludes unavailable catalog entries from connection priming', () => { + expect( + selectConnectableHostProfiles([ + { ...profile, credentialStatus: 'ready', profile }, + unavailable('host-locked', 'temporarily-unavailable'), + unavailable('host-missing', 'missing') + ]) + ).toEqual([profile]) + }) +}) diff --git a/mobile/src/transport/host-catalog-selection.ts b/mobile/src/transport/host-catalog-selection.ts new file mode 100644 index 00000000000..31477dd7f6d --- /dev/null +++ b/mobile/src/transport/host-catalog-selection.ts @@ -0,0 +1,7 @@ +import type { HostCatalogEntry, HostProfile } from './types' + +export function selectConnectableHostProfiles(catalog: readonly HostCatalogEntry[]): HostProfile[] { + return catalog.flatMap((entry) => + entry.credentialStatus === 'ready' && entry.profile ? [entry.profile] : [] + ) +} diff --git a/mobile/src/transport/host-credential-cleanup.test.ts b/mobile/src/transport/host-credential-cleanup.test.ts index bd483a3280f..146a845a96b 100644 --- a/mobile/src/transport/host-credential-cleanup.test.ts +++ b/mobile/src/transport/host-credential-cleanup.test.ts @@ -1,8 +1,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + cancelPendingHostCredentialCleanup, loadPendingHostCredentialCleanup, loadPendingHostCredentialCleanupIds, + recordHostCredentialCleanupIntent, resetHostCredentialCleanupForTests, retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup, @@ -66,6 +68,12 @@ describe('host credential cleanup', () => { expect(deleteCredential).toHaveBeenCalledOnce() }) + it('records cleanup intent without starting native deletion', async () => { + await recordHostCredentialCleanupIntent('host-1') + + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + }) + it('persists cleanup intent before a rejected delete for manual retry', async () => { const deleteCredential = vi.fn().mockRejectedValue(new Error('keychain unavailable')) const listener = vi.fn() @@ -78,6 +86,18 @@ describe('host credential cleanup', () => { unsubscribe() }) + it('cancels stale cleanup intent after replacement publication', async () => { + const deleteCredential = vi.fn().mockRejectedValue(new Error('superseded')) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 20) + await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce()) + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + + await cancelPendingHostCredentialCleanup('host-1') + + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) + it('records intent before a stalled delete times out', async () => { vi.useFakeTimers() const deleteCredential = vi.fn(() => new Promise(() => undefined)) diff --git a/mobile/src/transport/host-credential-cleanup.ts b/mobile/src/transport/host-credential-cleanup.ts index d64e37a98dc..96a2623b514 100644 --- a/mobile/src/transport/host-credential-cleanup.ts +++ b/mobile/src/transport/host-credential-cleanup.ts @@ -155,13 +155,17 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia async function recordCleanupIntent(hostId: string): Promise { try { - await addPendingId(hostId) + await recordHostCredentialCleanupIntent(hostId) return true } catch { return false } } +export async function recordHostCredentialCleanupIntent(hostId: string): Promise { + await addPendingId(hostId) +} + async function confirmNativeCleanup( hostId: string, deleteCredential: DeleteHostCredential, @@ -209,6 +213,11 @@ export function subscribePendingHostCredentialCleanup(listener: () => void): () return () => pendingListeners.delete(listener) } +export async function cancelPendingHostCredentialCleanup(hostId: string): Promise { + clearUnrecordedPending(hostId) + await removePendingId(hostId) +} + /** * Record cleanup intent, then fire-and-forget the native keychain delete so * removeHost never blocks on SecureStore. If the durable intent write fails, diff --git a/mobile/src/transport/host-credential-write-revision.ts b/mobile/src/transport/host-credential-write-revision.ts new file mode 100644 index 00000000000..12207aa6a12 --- /dev/null +++ b/mobile/src/transport/host-credential-write-revision.ts @@ -0,0 +1,21 @@ +let nextRevision = 1 +const revisionByHostId = new Map() + +export function getHostCredentialWriteRevision(hostId: string): number { + return revisionByHostId.get(hostId) ?? 0 +} + +export function markHostCredentialWrite(hostId: string): void { + revisionByHostId.set(hostId, nextRevision) + nextRevision += 1 +} + +export function clearHostCredentialWriteRevision(hostId: string): void { + revisionByHostId.delete(hostId) +} + +/** Test-only: clear session credential generations between cases. */ +export function resetHostCredentialWriteRevisionsForTests(): void { + nextRevision = 1 + revisionByHostId.clear() +} diff --git a/mobile/src/transport/host-device-token-store.ts b/mobile/src/transport/host-device-token-store.ts new file mode 100644 index 00000000000..297177e0dbe --- /dev/null +++ b/mobile/src/transport/host-device-token-store.ts @@ -0,0 +1,43 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { Platform } from 'react-native' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' + +// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. +const TOKEN_KEY_PREFIX = 'orca.host-token.' +const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' + +function tokenKey(hostId: string): string { + return `${TOKEN_KEY_PREFIX}${hostId}` +} + +function webTokenKey(hostId: string): string { + return `${WEB_TOKEN_KEY_PREFIX}${hostId}` +} + +export async function readHostDeviceToken(hostId: string): Promise { + // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. + if (Platform.OS === 'web') { + return AsyncStorage.getItem(webTokenKey(hostId)) + } + return readPairingKeychainItem(tokenKey(hostId)) +} + +export async function writeHostDeviceToken(hostId: string, token: string): Promise { + if (Platform.OS === 'web') { + await AsyncStorage.setItem(webTokenKey(hostId), token) + return + } + await writePairingKeychainItem(tokenKey(hostId), token) +} + +export async function deleteHostDeviceToken(hostId: string): Promise { + if (Platform.OS === 'web') { + await AsyncStorage.removeItem(webTokenKey(hostId)) + return + } + await deletePairingKeychainItem(tokenKey(hostId)) +} diff --git a/mobile/src/transport/host-list-load-sharing.ts b/mobile/src/transport/host-list-load-sharing.ts index ab1e9e2fe89..15995e3ba25 100644 --- a/mobile/src/transport/host-list-load-sharing.ts +++ b/mobile/src/transport/host-list-load-sharing.ts @@ -1,15 +1,22 @@ -import type { HostProfile } from './types' +import type { HostCatalogEntry, HostProfile } from './types' + +export type HostListSnapshot = { + catalog: HostCatalogEntry[] + profiles: HostProfile[] +} // Why: concurrent callers share a slow Keychain pass; durable writes invalidate // it so later loads cannot receive stale snapshots (#8791). -let inflight: Promise | null = null +let inflight: Promise | null = null let revision = 0 export function getHostListLoadRevision(): number { return revision } -export function shareHostListLoad(load: () => Promise): Promise { +export function shareHostListLoad( + load: () => Promise +): Promise { if (inflight) { return inflight } diff --git a/mobile/src/transport/host-metadata-store.ts b/mobile/src/transport/host-metadata-store.ts new file mode 100644 index 00000000000..7962f43dc37 --- /dev/null +++ b/mobile/src/transport/host-metadata-store.ts @@ -0,0 +1,51 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { StoredHostProfileSchema, type HostProfile, type StoredHostProfile } from './types' + +const STORAGE_KEY = 'orca:hosts' + +export async function loadStoredHostProfiles(): Promise { + return parseStoredHostProfiles(await AsyncStorage.getItem(STORAGE_KEY)) +} + +export async function readStoredHostProfilesForMutation(): Promise { + try { + const parsed = await loadStoredHostProfiles() + if (parsed) { + return parsed + } + } catch { + // Normalize storage and payload failures for fail-closed mutations. + } + throw new Error('host list storage unreadable') +} + +export function writeStoredHostProfiles(hosts: readonly StoredHostProfile[]): Promise { + return AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) +} + +export function toStoredHostProfile(host: HostProfile): StoredHostProfile { + const { id, name, endpoint, publicKeyB64, lastConnected } = host + return { id, name, endpoint, publicKeyB64, lastConnected } +} + +function parseStoredHostProfiles(raw: string | null): StoredHostProfile[] | null { + if (!raw) { + return [] + } + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) { + return null + } + return parsed.flatMap((item) => { + // Why: pre-v0.0.3 records embedded secrets; users re-pair instead of migrating them. + if (item && typeof item === 'object' && 'deviceToken' in item) { + return [] + } + const result = StoredHostProfileSchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return null + } +} diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 997e8a3d42f..ddb8f92a92b 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -14,6 +14,8 @@ const secureStoreMock = vi.hoisted(() => ({ })) const scheduleCleanupMock = vi.hoisted(() => vi.fn()) +const cancelCleanupMock = vi.hoisted(() => vi.fn()) +const recordCleanupIntentMock = vi.hoisted(() => vi.fn()) const platformMock = vi.hoisted(() => ({ OS: 'ios' })) vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -30,11 +32,14 @@ vi.mock('react-native', () => ({ })) vi.mock('./host-credential-cleanup', () => ({ + cancelPendingHostCredentialCleanup: (...args: unknown[]) => cancelCleanupMock(...args), + recordHostCredentialCleanupIntent: (...args: unknown[]) => recordCleanupIntentMock(...args), scheduleHostCredentialCleanup: (...args: unknown[]) => scheduleCleanupMock(...args), retryPendingHostCredentialCleanups: vi.fn() })) import { + loadHostCatalog, loadHosts, MobileRelayUpgradeHostRemovedError, removeHost, @@ -46,6 +51,7 @@ import { updateLastConnected } from './host-store' import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' +import { writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' const HOSTS_STORAGE_KEY = 'orca:hosts' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' @@ -63,6 +69,23 @@ const HOST_TWO = { publicKeyB64: 'key-2', lastConnected: 0 } +const HOST_ONE_RELAY_BUNDLE = { + v: 1 as const, + hostId: HOST_ONE.id, + deviceToken: 'replacement-token', + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 1, + expiresAt: 10_000 + } +} + +function scheduledCleanup(hostId: string): (id: string) => Promise { + const cleanup = scheduleCleanupMock.mock.calls.find(([id]) => id === hostId)?.[1] + expect(cleanup).toBeTypeOf('function') + return cleanup as (id: string) => Promise +} describe('host-store list mutations', () => { let storedHostsRaw: string @@ -75,6 +98,10 @@ describe('host-store list mutations', () => { resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) + cancelCleanupMock.mockReset() + cancelCleanupMock.mockResolvedValue(undefined) + recordCleanupIntentMock.mockReset() + recordCleanupIntentMock.mockResolvedValue(undefined) storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO]) storedOverlayRaw = null asyncStorageMock.getItem.mockImplementation(async (key: string) => { @@ -93,6 +120,7 @@ describe('host-store list mutations', () => { storedOverlayRaw = raw } }) + secureStoreMock.setItemAsync.mockResolvedValue(undefined) secureStoreMock.getItemAsync.mockImplementation(async (key: string) => key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null ) @@ -114,6 +142,55 @@ describe('host-store list mutations', () => { expect(asyncStorageMock.getItem).toHaveBeenCalledOnce() }) + it('keeps a host visible when its credential read temporarily fails', async () => { + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => { + if (key.endsWith(HOST_ONE.id)) { + throw new Error('keychain locked') + } + return key.endsWith(HOST_TWO.id) ? 'token-2' : null + }) + + const catalog = await loadHostCatalog() + const profiles = await loadHosts() + + expect(catalog.map(({ id }) => id)).toEqual([HOST_ONE.id, HOST_TWO.id]) + expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ + credentialStatus: 'temporarily-unavailable', + profile: null + }) + expect(profiles.map(({ id }) => id)).toEqual([HOST_TWO.id]) + }) + + it('keeps orphaned metadata visible when its credential is missing', async () => { + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => + key.endsWith(HOST_TWO.id) ? 'token-2' : null + ) + + const catalog = await loadHostCatalog() + + expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ + credentialStatus: 'missing', + profile: null + }) + }) + + it('keeps existing metadata when pairing a distinct host', async () => { + await saveHost({ + id: 'host-new', + name: 'Host 3', + endpoint: 'ws://127.0.0.1:3', + publicKeyB64: 'key-new', + deviceToken: 'token-new', + lastConnected: 0 + }) + + expect(JSON.parse(storedHostsRaw).map(({ id }: { id: string }) => id)).toEqual([ + HOST_ONE.id, + HOST_TWO.id, + 'host-new' + ]) + }) + it('fails closed when durable host identity storage is unreadable', async () => { asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable')) @@ -134,9 +211,173 @@ describe('host-store list mutations', () => { }) expect(JSON.parse(storedHostsRaw)).toEqual([{ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3' }]) + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + asyncStorageMock.setItem.mock.invocationCallOrder[0]! + ) expect(scheduleCleanupMock).toHaveBeenCalledWith('host-duplicate', expect.any(Function)) }) + it('does not remove same-key metadata when the replacement credential write fails', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + secureStoreMock.setItemAsync.mockRejectedValue(new Error('keychain write failed')) + + await expect( + saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' }) + ).rejects.toThrow('keychain write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate]) + expect(cancelCleanupMock).toHaveBeenCalledWith(duplicate.id) + expect(scheduleCleanupMock).not.toHaveBeenCalled() + }) + + it('serves a committed replacement token when same-key metadata persistence fails', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + await loadHosts() + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect( + saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' }) + ).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate]) + await expect( + loadHosts().then((hosts) => hosts.find(({ id }) => id === HOST_ONE.id)?.deviceToken) + ).resolves.toBe('replacement-token') + }) + + it('records cleanup when new same-key metadata fails after its credential commits', async () => { + const replacement = { + ...HOST_TWO, + id: 'host-replacement', + publicKeyB64: HOST_ONE.publicKeyB64, + deviceToken: 'replacement-token' + } + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect(saveHost(replacement)).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(recordCleanupIntentMock).toHaveBeenCalledWith(replacement.id) + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + secureStoreMock.setItemAsync.mock.invocationCallOrder[0]! + ) + expect(scheduleCleanupMock).toHaveBeenCalledWith(replacement.id, expect.any(Function)) + }) + + it('does not clean a duplicate id that a later same-key save made authoritative', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + await saveHost({ ...HOST_ONE, deviceToken: 'token-a' }) + const staleCleanup = scheduledCleanup(duplicate.id) + + await saveHost({ ...duplicate, deviceToken: 'token-b' }) + await staleCleanup(duplicate.id) + + expect(JSON.parse(storedHostsRaw)).toEqual([duplicate]) + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('does not let stale removal cleanup delete a replacement relay bundle before publication', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + await writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + + await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('keeps stale cleanup pending when a replacement relay write fails', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + secureStoreMock.setItemAsync.mockRejectedValueOnce(new Error('keychain write failed')) + + await expect(writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)).rejects.toThrow( + 'keychain write failed' + ) + await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('stops stale cleanup when a replacement relay write starts during token deletion', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + let releaseTokenDelete: () => void = () => {} + secureStoreMock.deleteItemAsync.mockReturnValueOnce( + new Promise((resolve) => { + releaseTokenDelete = resolve + }) + ) + + const cleanup = staleCleanup(HOST_ONE.id) + await vi.waitFor(() => { + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-1', + expect.anything() + ) + }) + const bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + releaseTokenDelete() + await bundleWrite + await expect(cleanup).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalledWith( + 'orca.mobile-relay.credentials.host-1', + expect.anything() + ) + }) + + it('rechecks the write generation immediately before starting credential deletion', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + let resolveCleanupRead: (raw: string) => void = () => {} + const cleanupRead = new Promise((resolve) => { + resolveCleanupRead = resolve + }) + let cleanupReadStarted = false + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === HOSTS_STORAGE_KEY && !cleanupReadStarted) { + cleanupReadStarted = true + return cleanupRead + } + if (key === HOSTS_STORAGE_KEY) { + return storedHostsRaw + } + if (key === OVERLAY_STORAGE_KEY) { + return storedOverlayRaw + } + return null + }) + + const cleanup = staleCleanup(HOST_ONE.id) + await vi.waitFor(() => expect(cleanupReadStarted).toBe(true)) + let bundleWrite: Promise | null = null + resolveCleanupRead(storedHostsRaw) + queueMicrotask(() => { + bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + }) + await expect(cleanup).rejects.toThrow('credential write superseded cleanup') + await vi.waitFor(() => expect(bundleWrite).not.toBeNull()) + await bundleWrite + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + it('clears stale relay state when an existing host is re-paired direct-only', async () => { const overlay: MobileRelayHostOverlay = { v: 2, @@ -209,7 +450,63 @@ describe('host-store list mutations', () => { expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) }) - it('merges v2 endpoints only onto an existing legacy base host', async () => { + it('cancels write-ahead cleanup when host metadata removal fails', async () => { + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect(removeHost(HOST_ONE.id)).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(cancelCleanupMock).toHaveBeenCalledWith(HOST_ONE.id) + }) + + it('does not cancel cleanup from a removal committed during a stalled save', async () => { + let releaseTokenWrite: () => void = () => {} + secureStoreMock.setItemAsync.mockReturnValueOnce( + new Promise((resolve) => { + releaseTokenWrite = resolve + }) + ) + + const save = saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) + await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledOnce()) + await removeHost(HOST_ONE.id) + releaseTokenWrite() + await save + await loadHostCatalog() + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) + expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) + expect(cancelCleanupMock).not.toHaveBeenCalled() + }) + + it('refuses an early same-key token write without durable cleanup intent', async () => { + const replacement = { + ...HOST_TWO, + id: 'host-replacement', + publicKeyB64: HOST_ONE.publicKeyB64, + deviceToken: 'replacement-token' + } + recordCleanupIntentMock.mockRejectedValueOnce(new Error('intent storage unavailable')) + + await expect(saveHost(replacement)).rejects.toThrow('intent storage unavailable') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + + it('deletes an unpaired credential when cleanup runs without a newer write', async () => { + await removeHost(HOST_ONE.id) + const cleanup = scheduledCleanup(HOST_ONE.id) + + await cleanup(HOST_ONE.id) + + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-1', + expect.anything() + ) + }) + + it('keeps a concurrently re-published orphan overlay authoritative', async () => { const overlay: MobileRelayHostOverlay = { v: 2, hostId: HOST_ONE.id, @@ -232,8 +529,26 @@ describe('host-store list mutations', () => { } } storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }]) + let releaseCleanup: () => void = () => {} + scheduleCleanupMock.mockReturnValueOnce( + new Promise((resolve) => { + releaseCleanup = resolve + }) + ) - const hosts = await loadHosts() + const hostsLoad = loadHosts() + await vi.waitFor(() => expect(scheduleCleanupMock).toHaveBeenCalled()) + await saveHost({ + ...HOST_ONE, + id: 'removed-by-old-build', + publicKeyB64: 'restored-key', + deviceToken: 'restored-token', + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + }) + releaseCleanup() + const hosts = await hostsLoad expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ endpoints: overlay.endpoints, @@ -241,6 +556,10 @@ describe('host-store list mutations', () => { relay: overlay.relay }) expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false) + expect(JSON.parse(storedOverlayRaw!)).toContainEqual({ + ...overlay, + hostId: 'removed-by-old-build' + }) }) it('refuses to resurrect a removed host during relay upgrade publication', async () => { @@ -270,7 +589,9 @@ describe('host-store list mutations', () => { expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) }) expect(settled).toBe(false) - expect(scheduleCleanupMock).toHaveBeenCalledOnce() + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + asyncStorageMock.setItem.mock.invocationCallOrder[0]! + ) resolveSchedule?.() await removal @@ -522,6 +843,10 @@ describe('host-store pairing save after an Android encryption rejection', () => resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) + cancelCleanupMock.mockReset() + cancelCleanupMock.mockResolvedValue(undefined) + recordCleanupIntentMock.mockReset() + recordCleanupIntentMock.mockResolvedValue(undefined) storedHostsRaw = '[]' storedGenerationRaw = null asyncStorageMock.getItem.mockImplementation(async (key: string) => { diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 43657fffe76..99421ffd192 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -1,20 +1,13 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { Platform } from 'react-native' -import { - HostProfileSchema, - StoredHostProfileSchema, - type HostProfile, - type StoredHostProfile -} from './types' +import { HostProfileSchema } from './types' +import type { HostCatalogEntry, HostProfile, StoredHostProfile } from './types' import { getNextHostNameFromHosts } from './host-names' import * as hostListLoads from './host-list-load-sharing' +import { joinHostCatalogCredentials } from './host-catalog-credential-join' +import { resetPairingKeychainForTests } from './pairing-keychain' +import { readHostDeviceToken, writeHostDeviceToken } from './host-device-token-store' import { - deletePairingKeychainItem, - readPairingKeychainItem, - resetPairingKeychainForTests, - writePairingKeychainItem -} from './pairing-keychain' -import { + cancelPendingHostCredentialCleanup, + recordHostCredentialCleanupIntent, retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup } from './host-credential-cleanup' @@ -24,137 +17,67 @@ import { removeMobileRelayHostOverlays, saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' -import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' -import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' +import { + getHostCredentialWriteRevision, + markHostCredentialWrite, + resetHostCredentialWriteRevisionsForTests +} from './host-credential-write-revision' +import { createUnpairedHostCredentialDeletion } from './unpaired-host-credential-deletion' +import { + loadStoredHostProfiles, + readStoredHostProfilesForMutation, + toStoredHostProfile, + writeStoredHostProfiles +} from './host-metadata-store' -const STORAGE_KEY = 'orca:hosts' -// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. -const TOKEN_KEY_PREFIX = 'orca.host-token.' -const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' - -function tokenKey(hostId: string): string { - return `${TOKEN_KEY_PREFIX}${hostId}` -} - -function webTokenKey(hostId: string): string { - return `${WEB_TOKEN_KEY_PREFIX}${hostId}` -} - -async function readDeviceToken(hostId: string): Promise { - // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. - if (Platform.OS === 'web') { - return AsyncStorage.getItem(webTokenKey(hostId)) - } - return readPairingKeychainItem(tokenKey(hostId)) -} - -async function writeDeviceToken(hostId: string, token: string): Promise { - if (Platform.OS === 'web') { - await AsyncStorage.setItem(webTokenKey(hostId), token) - return - } - await writePairingKeychainItem(tokenKey(hostId), token) -} - -async function deleteDeviceToken(hostId: string): Promise { - if (Platform.OS === 'web') { - await AsyncStorage.removeItem(webTokenKey(hostId)) - return - } - await deletePairingKeychainItem(tokenKey(hostId)) -} - -async function deleteHostCredentials(hostId: string): Promise { - await deleteDeviceToken(hostId) - await deleteMobileRelayCredentialBundle(hostId) - await deleteMobileRelayDirectUpgradeJournal(hostId) +async function commitDeviceToken(hostId: string, token: string): Promise { + markHostCredentialWrite(hostId) + await writeHostDeviceToken(hostId, token) + tokenCache.set(hostId, token) + hostListLoads.dropSharedHostListLoad() } // Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove. const tokenCache = new Map() -// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename). +// Why: serialize host metadata RMW so concurrent writers cannot drop updates. let hostListMutation: Promise = Promise.resolve() -function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { - if (!raw) { - return [] - } - try { - const parsed = JSON.parse(raw) as unknown - if (!Array.isArray(parsed)) { - return null - } - return parsed.flatMap((item) => { - // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim. - if (item && typeof item === 'object' && 'deviceToken' in item) { - return [] - } - const result = StoredHostProfileSchema.safeParse(item) - return result.success ? [result.data] : [] - }) - } catch { - return null - } -} +export const loadHosts = async (): Promise => (await loadHostListSnapshot()).profiles +export const loadHostCatalog = async (): Promise => + (await loadHostListSnapshot()).catalog -export async function loadHosts(): Promise { +async function loadHostListSnapshot(): Promise { // Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list. await hostListMutation // Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass. - return hostListLoads.shareHostListLoad(doLoadHosts) + return hostListLoads.shareHostListLoad(doLoadHostListSnapshot) } -async function doLoadHosts(): Promise { - const raw = await AsyncStorage.getItem(STORAGE_KEY) - const storedHosts = parseStoredHosts(raw) +async function doLoadHostListSnapshot(): Promise { + const storedHosts = await loadStoredHostProfiles() if (!storedHosts) { - return [] + return { catalog: [], profiles: [] } } const overlayState = await loadMobileRelayHostOverlayState( new Set(storedHosts.map(({ id }) => id)) ) + const orphanWriteRevisions = new Map( + overlayState.orphanHostIds.map((hostId) => [hostId, getHostCredentialWriteRevision(hostId)]) + ) await scheduleOrphanedMobileRelayCleanup({ hostIds: overlayState.orphanHostIds, - deleteCredential: deleteHostCredentials + deleteCredential: (hostId) => + deleteUnpairedHostCredentials(hostId, orphanWriteRevisions.get(hostId) ?? 0), + removeOverlay: removeOrphanOverlayIfUnpaired + }) + return joinHostCatalogCredentials({ + storedHosts, + overlays: overlayState.overlays, + tokenCache, + readToken: readHostDeviceToken, + getRevision: hostListLoads.getHostListLoadRevision }) - const overlays = overlayState.overlays - - const out: HostProfile[] = [] - for (const stored of storedHosts) { - let token = tokenCache.get(stored.id) - if (!token) { - const readRevision = hostListLoads.getHostListLoadRevision() - let fetched: string | null - try { - fetched = await readDeviceToken(stored.id) - } catch { - // Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it. - continue - } - if (!fetched) { - // Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host. - continue - } - token = fetched - if (readRevision === hostListLoads.getHostListLoadRevision()) { - tokenCache.set(stored.id, token) - } - } - const overlay = overlays.get(stored.id) - out.push({ - ...stored, - deviceToken: token, - ...(overlay - ? { - endpoints: overlay.endpoints, - relayHostId: overlay.relayHostId, - relay: overlay.relay - } - : {}) - }) - } - return out } export async function resolvePairingHostIdentity( @@ -163,91 +86,146 @@ export async function resolvePairingHostIdentity( ): Promise<{ id: string; name: string }> { // Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards. await hostListMutation - const hosts = await readStoredHostsForMutation() + const hosts = await readStoredHostProfilesForMutation() const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64) return match ? { id: match.id, name: match.name } : { id: newHostId, name: getNextHostNameFromHosts(hosts) } } -async function readStoredHostsForMutation(): Promise { - try { - const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) - if (!parsed) { - // Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write. - throw new Error('host list storage unreadable') - } - return parsed - } catch (error) { - if (error instanceof Error && error.message === 'host list storage unreadable') { - throw error - } - throw new Error('host list storage unreadable') +const deleteUnpairedHostCredentials = createUnpairedHostCredentialDeletion({ + waitForHostMutations: () => hostListMutation, + hasStoredHost: async (hostId) => + (await readStoredHostProfilesForMutation()).some(({ id }) => id === hostId), + onDeleted: (hostId) => { + tokenCache.delete(hostId) + hostListLoads.dropSharedHostListLoad() } +}) + +function scheduleUnpairedHostCredentialCleanup(hostId: string): Promise { + const writeRevision = getHostCredentialWriteRevision(hostId) + return scheduleHostCredentialCleanup(hostId, (id) => + deleteUnpairedHostCredentials(id, writeRevision) + ) } -async function mutateStoredHosts( - update: (hosts: StoredHostProfile[]) => StoredHostProfile[] -): Promise { - const mutation = hostListMutation.then(async () => { - const current = await readStoredHostsForMutation() - const next = update(current) - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)) - hostListLoads.dropSharedHostListLoad() +function cancelCleanupForStoredHost(hostId: string): void { + const cancellation = hostListMutation.then(async () => { + const hosts = await readStoredHostProfilesForMutation() + if (hosts.some(({ id }) => id === hostId)) { + // Register before later removals enqueue their intent, without blocking host loads on cleanup storage. + void cancelPendingHostCredentialCleanup(hostId).catch(() => undefined) + } }) + hostListMutation = cancellation.catch(() => {}) +} + +async function cancelCleanupForDurablyStoredHosts(hostIds: Iterable): Promise { + const targets = [...hostIds] + return enqueueHostListMutation(async () => { + const storedIds = new Set((await readStoredHostProfilesForMutation()).map(({ id }) => id)) + await Promise.all( + targets + .filter((hostId) => storedIds.has(hostId)) + .map((hostId) => cancelPendingHostCredentialCleanup(hostId).catch(() => undefined)) + ) + }).catch(() => undefined) +} + +function enqueueHostListMutation(operation: () => Promise): Promise { + const mutation = hostListMutation.then(operation) hostListMutation = mutation.catch(() => {}) return mutation } -function toStored(host: HostProfile): StoredHostProfile { - return { - id: host.id, - name: host.name, - endpoint: host.endpoint, - publicKeyB64: host.publicKeyB64, - lastConnected: host.lastConnected - } +function removeOrphanOverlayIfUnpaired(hostId: string): Promise { + return enqueueHostListMutation(async () => { + const hosts = await readStoredHostProfilesForMutation() + if (!hosts.some(({ id }) => id === hostId)) { + await removeMobileRelayHostOverlay(hostId) + } + }) +} + +async function mutateStoredHosts( + update: (hosts: StoredHostProfile[]) => StoredHostProfile[] | Promise +): Promise { + return enqueueHostListMutation(async () => { + const current = await readStoredHostProfilesForMutation() + const next = await update(current) + await writeStoredHostProfiles(next) + hostListLoads.dropSharedHostListLoad() + }) } export class MobileRelayUpgradeHostRemovedError extends Error {} -export async function saveHost(host: HostProfile): Promise { - await persistHost(host, false) -} +export const saveHost = (host: HostProfile): Promise => persistHost(host, false) -export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise { - await persistHost(host, true) -} +export const saveExistingHostRelayUpgrade = (host: HostProfile): Promise => + persistHost(host, true) async function persistHost(host: HostProfile, requireExisting: boolean): Promise { const validated = HostProfileSchema.parse(host) - const stored = toStored(validated) + const stored = toStoredHostProfile(validated) const duplicateHostIds = new Set() let updatedExistingHost = false - await mutateStoredHosts((hosts) => { - const index = hosts.findIndex((h) => h.id === stored.id) - for (const candidate of hosts) { - if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) { - duplicateHostIds.add(candidate.id) + let cleanupIntentRecordedBeforeMetadata = false + let tokenCommittedBeforeMetadata = false + try { + await mutateStoredHosts(async (hosts) => { + const index = hosts.findIndex((h) => h.id === stored.id) + for (const candidate of hosts) { + if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) { + duplicateHostIds.add(candidate.id) + } + } + let next: StoredHostProfile[] + if (index >= 0) { + updatedExistingHost = true + // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. + next = hosts + .filter(({ id }) => !duplicateHostIds.has(id)) + .map((candidate) => (candidate.id === stored.id ? stored : candidate)) + } else if (requireExisting) { + // Why: an in-flight relay upgrade must not resurrect a host the user removed. + throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') + } else { + next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] + } + if (duplicateHostIds.size > 0) { + if (index < 0) { + // Why: process death between the early token write and metadata publication must leave cleanup discoverable. + await recordHostCredentialCleanupIntent(stored.id) + cleanupIntentRecordedBeforeMetadata = true + } + for (const duplicateHostId of duplicateHostIds) { + await recordHostCredentialCleanupIntent(duplicateHostId) + } + // Why: never remove the only usable same-key row until its replacement credential is durable. + await commitDeviceToken(stored.id, validated.deviceToken) + tokenCommittedBeforeMetadata = true + } + return next + }) + } catch (error) { + await cancelCleanupForDurablyStoredHosts(duplicateHostIds) + if (cleanupIntentRecordedBeforeMetadata) { + try { + await scheduleUnpairedHostCredentialCleanup(stored.id) + } catch { + // The write-ahead cleanup intent remains available for retry. } } - if (index >= 0) { - updatedExistingHost = true - // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. - return hosts - .filter(({ id }) => !duplicateHostIds.has(id)) - .map((candidate) => (candidate.id === stored.id ? stored : candidate)) - } - if (requireExisting) { - // Why: an in-flight relay upgrade must not resurrect a host the user removed. - throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') - } - return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] - }) - // Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever. - await writeDeviceToken(stored.id, validated.deviceToken) - tokenCache.set(stored.id, validated.deviceToken) - hostListLoads.dropSharedHostListLoad() + throw error + } + if (!tokenCommittedBeforeMetadata) { + // Why: the catalog can now surface a failed token write for recovery instead of losing the host. + await commitDeviceToken(stored.id, validated.deviceToken) + } + // Why: a later removal owns its cleanup intent; cancel only while this publication remains authoritative. + cancelCleanupForStoredHost(stored.id) if (validated.endpoints) { await saveMobileRelayHostOverlay({ v: 2, @@ -268,9 +246,8 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise hostListLoads.dropSharedHostListLoad() } for (const duplicateHostId of duplicateHostIds) { - tokenCache.delete(duplicateHostId) try { - await scheduleHostCredentialCleanup(duplicateHostId, deleteHostCredentials) + await scheduleUnpairedHostCredentialCleanup(duplicateHostId) } catch { // Metadata is already deduplicated; orphan-token recovery is best-effort. } @@ -278,7 +255,23 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise } export async function removeHost(hostId: string): Promise { - await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId)) + let cleanupIntentRecorded = false + try { + await mutateStoredHosts(async (hosts) => { + try { + await recordHostCredentialCleanupIntent(hostId) + cleanupIntentRecorded = true + } catch { + // Removal remains authoritative when cleanup intent storage is unavailable. + } + return hosts.filter((h) => h.id !== hostId) + }) + } catch (error) { + if (cleanupIntentRecorded) { + await cancelCleanupForDurablyStoredHosts([hostId]) + } + throw error + } tokenCache.delete(hostId) try { await removeMobileRelayHostOverlay(hostId) @@ -288,7 +281,7 @@ export async function removeHost(hostId: string): Promise { } // Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI. try { - await scheduleHostCredentialCleanup(hostId, deleteHostCredentials) + await scheduleUnpairedHostCredentialCleanup(hostId) } catch { // Metadata is already committed; orphan-token recovery is best-effort. } @@ -299,7 +292,9 @@ export async function retryPendingHostCredentialCleanup(): Promise<{ remainingIds: string[] storageUnreadable: boolean }> { - return retryPendingHostCredentialCleanups(deleteHostCredentials) + return retryPendingHostCredentialCleanups((hostId) => + deleteUnpairedHostCredentials(hostId, getHostCredentialWriteRevision(hostId)) + ) } // Why: single mutation pass commits name + endpoint atomically so a mid-save failure can't persist one without the other. @@ -342,6 +337,7 @@ export async function updateLastConnected(hostId: string): Promise { export function resetHostStoreForTests(): void { hostListMutation = Promise.resolve() tokenCache.clear() + resetHostCredentialWriteRevisionsForTests() hostListLoads.dropSharedHostListLoad() resetPairingKeychainForTests() } diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts index a08fa052459..089c6d28c56 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -7,6 +7,7 @@ import { readPairingKeychainItem, writePairingKeychainItem } from './pairing-keychain' +import { markHostCredentialWrite } from './host-credential-write-revision' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) const ResumeCredentialSchema = z @@ -91,6 +92,7 @@ export async function writeMobileRelayCredentialBundle( ): Promise { requireNativeSecretStore() const validated = MobileRelayCredentialBundleSchema.parse(bundle) + markHostCredentialWrite(validated.hostId) await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated)) } diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 2493c9e78af..e21e75a6b61 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -6,6 +6,7 @@ import { readPairingKeychainItem, writePairingKeychainItem } from './pairing-keychain' +import { markHostCredentialWrite } from './host-credential-write-revision' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) @@ -60,6 +61,7 @@ export async function writeMobileRelayDirectUpgradeJournal( ): Promise { requireNativeSecretStore() const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) + markHostCredentialWrite(parsed.hostId) await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed)) } diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 400deee90a6..9d96f0561ad 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -75,6 +75,13 @@ export type HostProfile = { relay?: MobileRelayHostOverlay['relay'] } +export type HostCredentialStatus = 'ready' | 'temporarily-unavailable' | 'missing' + +export type HostCatalogEntry = Omit & { + credentialStatus: HostCredentialStatus + profile: HostProfile | null +} + export const HostProfileSchema = z.object({ id: z.string().min(1), name: z.string().min(1), diff --git a/mobile/src/transport/unpaired-host-credential-deletion.ts b/mobile/src/transport/unpaired-host-credential-deletion.ts new file mode 100644 index 00000000000..cc9c27e49ad --- /dev/null +++ b/mobile/src/transport/unpaired-host-credential-deletion.ts @@ -0,0 +1,58 @@ +import { deleteHostDeviceToken } from './host-device-token-store' +import { + clearHostCredentialWriteRevision, + getHostCredentialWriteRevision +} from './host-credential-write-revision' +import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' + +type DeletionDependencies = { + waitForHostMutations: () => Promise + hasStoredHost: (hostId: string) => Promise + onDeleted: (hostId: string) => void +} + +export function createUnpairedHostCredentialDeletion(dependencies: DeletionDependencies) { + function writeRevisionChanged(hostId: string, writeRevision: number): boolean { + return getHostCredentialWriteRevision(hostId) !== writeRevision + } + + function assertWriteRevisionUnchanged(hostId: string, writeRevision: number): void { + if (writeRevisionChanged(hostId, writeRevision)) { + throw new Error('credential write superseded cleanup') + } + } + + async function shouldSkip(hostId: string, writeRevision: number): Promise { + await dependencies.waitForHostMutations() + if (await dependencies.hasStoredHost(hostId)) { + return true + } + assertWriteRevisionUnchanged(hostId, writeRevision) + return false + } + + return async (hostId: string, writeRevision: number): Promise => { + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteHostDeviceToken(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteMobileRelayCredentialBundle(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteMobileRelayDirectUpgradeJournal(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + clearHostCredentialWriteRevision(hostId) + dependencies.onDeleted(hostId) + } +} From 7da9368b786ab5ca5f78a4f1e0c03019a4f15882 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:12:32 -0700 Subject: [PATCH 27/46] fix(terminal): fence detached daemon endpoint ownership (#12709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): fence daemon endpoint ownership * fix(terminal): clean failed daemon PID claims * fix(terminal): close daemon ownership review gaps * test(daemon): release startup IPC in boot smoke * test(daemon): mirror production stdio in boot smoke * fix(daemon): exit after rpc shutdown cleanup * fix(terminal): make the socket name the daemon endpoint authority The reported failure was a live daemon hosting PTYs that nothing could reach: terminals acknowledged input and never ran it, listings diverged from reality, and restarting the app never helped because the detached helper survived. The ownership fence added for it could not fire in the sequence that produces the split brain. libuv unlinks the pathname a server bound to when that server closes, with no ownership check. A daemon that lost its endpoint name therefore deleted whichever socket then sat at that path — including a live replacement's — stranding a daemon that still hosted every session. Bind a private same-directory name and hard-link it into place instead: libuv can only ever unlink our own bind name, the exclusive link is a kernel-enforced endpoint claim, and the canonical name is removed only under an inode ownership check. The bind name replaces the basename rather than extending it, so it cannot overflow sun_path. killStaleDaemon removed the PID record unconditionally immediately before every fork, so the exclusive PID claim was always uncontested at bind time. It also unlinked a live daemon's endpoint whenever a connect probe merely timed out, and treated a `ps` timeout as proof of PID recycling. Now only positive evidence of a dead endpoint authorizes reclaiming it, SIGKILL is confirmed rather than assumed, and a daemon that cannot be proven stopped keeps its record and endpoint while the launcher refuses to fork beside it. A daemon whose endpoint was taken over now retires itself, draining rather than killing, so an unreachable orphan stops being permanent. A repaired PID record re-derives entryPath, appVersion and the Linux incarnation markers from the authenticated owner instead of dropping them; without appVersion a healthy daemon read as a permanently stale bundle and, on Windows, went unpinned against daemon-host pruning. Repair failure now fails open — abandoning a healthy daemon over a pid file write cost every persistent terminal on the machine. Also: treat only ENOENT as an unclaimed record so a Windows file lock is not reported as an ownership conflict; settle start() before close() so an accepted connection cannot defer it forever; sweep abandoned claim and bind names; and type the endpoint-identity seam so a rename cannot silently disable the fence. Adds a real-process handover smoke that reproduces the failure with two daemons racing one endpoint, and wires it into the native-smoke job. * fix(daemon): retire only on proven endpoint ownership loss The ownership watchdog read a null identity for any stat failure, so a transient EACCES or EIO on the runtime directory would retire a daemon that was still serving every terminal on the machine. Distinguish "the entry is gone" from "the probe failed" and act only on the former. Also require the loss to persist across two polls: a replacement publishes by unlink-then-link, and a single observation can land in that gap. * fix(daemon): source repaired ownership metadata from the authenticated hello Adversarial review found three defects in the previous two commits. Re-deriving entryPath from the owner's command line truncated it at the first space. A command line is a single space-joined string, so `C:\Program Files\Orca\...` and `/Applications/Orca 2.app/...` came back as `"C:\Program` and `/Applications/Orca`. getDaemonLaunchIdentity treats a present entryPath as authoritative, so a healthy daemon read as `different_app_path` and was killed and re-forked — worse than the missing-metadata case the derivation was added to fix. Carry entryPath and appVersion as optional fields on the daemon hello identity instead: the daemon already has both from its own argv, and per docs/reference/remote-wire-compatibility.md a new optional field is safe because every reader falls back when it is absent. This also removes a synchronous `ps` spawn from the Electron main thread during startup. `start()` rolled back the PID record even when it never published one. Losing the endpoint link now runs that path, and the ownership-checked unlink briefly renames the incumbent's record aside — enough to strand a live daemon's ownership. Roll back only what we actually wrote. publishDaemonSocketPath read its identity from the canonical name after linking, so a concurrent unlink returned null: no ownership watchdog and no endpoint cleanup on any shutdown path. Read it from the bound name before linking, which shares the inode. Refusing to fork beside an unconfirmed daemon left the user with no daemon at all and no in-app recovery, since restart re-entered the same fence. We have just proved something answers the endpoint, so adopt it in degraded mode: live sessions keep working, fresh terminals run locally. SIGTERM is also individually guarded now — an EPERM fell into the blanket catch and reported "nothing alive", authorizing the very duplicate this fence exists to prevent. Also reset the ownership-loss streak on an inconclusive probe so the confirmations are consecutive, and sweep scratch names before the launch so a failed launch still reclaims them. --- .github/workflows/computer-e2e.yml | 6 + config/scripts/daemon-boot-smoke.mjs | 140 +++-- .../daemon-endpoint-handover-smoke.mjs | 164 ++++++ src/main/daemon/client.ts | 16 +- .../daemon/daemon-endpoint-ownership.test.ts | 271 ++++++++++ src/main/daemon/daemon-endpoint-ownership.ts | 169 ++++++ src/main/daemon/daemon-entry.test.ts | 24 + src/main/daemon/daemon-entry.ts | 48 +- .../daemon-health-socket-cleanup.test.ts | 2 +- src/main/daemon/daemon-health.test.ts | 135 ++++- src/main/daemon/daemon-health.ts | 131 ++++- src/main/daemon/daemon-hello-protocol.ts | 3 + src/main/daemon/daemon-init.test.ts | 510 +++++++++++++++--- src/main/daemon/daemon-init.ts | 212 ++++++-- src/main/daemon/daemon-main.ts | 10 + src/main/daemon/daemon-ready-identity.ts | 21 + src/main/daemon/daemon-server.test.ts | 11 +- src/main/daemon/daemon-server.ts | 202 ++++++- src/main/daemon/daemon-spawner.test.ts | 223 +++++++- src/main/daemon/daemon-spawner.ts | 65 ++- src/main/daemon/production-launcher.test.ts | 94 +++- src/main/daemon/production-launcher.ts | 71 ++- 22 files changed, 2279 insertions(+), 249 deletions(-) create mode 100644 config/scripts/daemon-endpoint-handover-smoke.mjs create mode 100644 src/main/daemon/daemon-endpoint-ownership.test.ts create mode 100644 src/main/daemon/daemon-endpoint-ownership.ts diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 731521ea8d6..f6f2c0bd13c 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -20,6 +20,7 @@ on: - 'config/scripts/computer-use-smoke.mjs' - 'config/scripts/computer-use-smoke.test.mjs' - 'config/scripts/daemon-boot-smoke.mjs' + - 'config/scripts/daemon-endpoint-handover-smoke.mjs' - 'config/scripts/windows-daemon-workspace-close-repro.mjs' - 'config/scripts/verify-computer-native.mjs' # Why: the native-smoke job boots the built terminal daemon under plain @@ -135,6 +136,11 @@ jobs: # the PR when the built daemon cannot start on ubuntu-22.04 / windows. - name: Daemon boot smoke run: node config/scripts/daemon-boot-smoke.mjs + # Why: a daemon that lost its endpoint name used to delete the replacement's + # socket on exit, stranding a live daemon that hosts PTYs nothing can reach. + # Only two real processes racing the same endpoint reproduce it. + - name: Daemon endpoint handover smoke + run: node config/scripts/daemon-endpoint-handover-smoke.mjs # Why: workspace removal overlaps graceful renderer teardown with the # forced main-process sweep; exercise that exact pair against real ConPTY. - name: Windows daemon workspace-close repro diff --git a/config/scripts/daemon-boot-smoke.mjs b/config/scripts/daemon-boot-smoke.mjs index 4ed155e8c5f..22c78a21cc2 100644 --- a/config/scripts/daemon-boot-smoke.mjs +++ b/config/scripts/daemon-boot-smoke.mjs @@ -18,7 +18,7 @@ import { fork } from 'node:child_process' import { connect } from 'node:net' import { randomUUID } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -54,30 +54,27 @@ function makeSocketPath(userDataDir) { return join(userDataDir, 'daemon.sock') } -// Best-effort end-to-end PTY check over the daemon's own control-socket RPC. -// Connects a single control socket, completes the hello handshake, and calls -// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves -// true on success, false on any failure — never throws. -function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { - return new Promise((resolveCheck) => { +function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) { + return new Promise((resolveRpc, rejectRpc) => { let settled = false let buffer = '' const socket = connect(socketPath) - const finish = (ok, reason) => { + const finish = (error, response) => { if (settled) { return } settled = true clearTimeout(timer) socket.destroy() - if (!ok && reason) { - log(`PTY spawn health check skipped (best-effort): ${reason}`) + if (error) { + rejectRpc(error) + } else { + resolveRpc(response) } - resolveCheck(ok) } - const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS) + const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs) - socket.on('error', (err) => finish(false, err.message)) + socket.on('error', (error) => finish(error)) socket.on('connect', () => { const token = readFileSync(tokenPath, 'utf8').trim() socket.write( @@ -100,19 +97,19 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { try { msg = JSON.parse(line) } catch { - finish(false, 'invalid response line') + finish(new Error('invalid response line')) return } if (msg.type === 'hello') { if (!msg.ok) { - finish(false, `hello rejected: ${msg.error ?? 'unknown'}`) + finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`)) return } - socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`) - } else if (msg.id === 'health-1') { + socket.write(`${JSON.stringify(request)}\n`) + } else if (msg.id === request.id) { finish( - msg.ok === true, - msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed') + msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`), + msg ) return } @@ -122,28 +119,61 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { }) } +// Best-effort: constrained CI runners can make node-pty spawn flaky. +async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { + try { + await runDaemonRpc( + socketPath, + tokenPath, + protocolVersion, + { id: 'health-1', type: 'ptySpawnHealth' }, + PTY_HEALTH_TIMEOUT_MS + ) + return true + } catch (error) { + log(`PTY spawn health check skipped (best-effort): ${error.message}`) + return false + } +} + async function main() { const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-')) const socketPath = makeSocketPath(userDataDir) const tokenPath = join(userDataDir, 'daemon.token') + const pidPath = join(userDataDir, 'daemon.pid') + const launchNonce = randomUUID() const protocolVersion = readProtocolVersion() log(`forking ${entryPath} under plain Node (${process.execPath})`) - const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { - // Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in - // CI, and this is exactly the runtime where a leaked `require("electron")` - // throws MODULE_NOT_FOUND — the failure this smoke exists to catch. - stdio: ['ignore', 'pipe', 'pipe', 'ipc'], - env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir } - }) + const child = fork( + entryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + launchNonce, + '--entry-path', + entryPath, + '--app-version', + 'daemon-boot-smoke' + ], + { + // Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in + // CI, and this is exactly the runtime where a leaked `require("electron")` + // throws MODULE_NOT_FOUND — the failure this smoke exists to catch. + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir } + } + ) let stderr = '' child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8') }) - child.stdout?.on('data', (chunk) => { - process.stdout.write(chunk) - }) const cleanup = () => { if (child.exitCode === null && child.signalCode === null && child.pid) { @@ -185,6 +215,25 @@ async function main() { }) }) log('daemon signaled ready') + const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8')) + if ( + pidRecord.pid !== child.pid || + pidRecord.launchNonce !== launchNonce || + pidRecord.entryPath !== entryPath || + pidRecord.appVersion !== 'daemon-boot-smoke' + ) { + throw new Error('daemon readiness did not publish the expected PID ownership record') + } + log('PID ownership record matches the ready daemon') + if (!existsSync(socketPath)) { + throw new Error('daemon did not publish its endpoint at the canonical socket path') + } + log('endpoint published at the canonical socket path') + // Production releases startup-only handles after ready; they can pin the child on Windows. + // Diagnostics past this point therefore carry the tail captured up to readiness only. + child.stderr?.destroy() + stderr += '[boot-smoke] stderr released at readiness, mirroring production\n' + child.disconnect() const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) if (ptyHealthy) { @@ -193,18 +242,39 @@ async function main() { await new Promise((resolveExit, rejectExit) => { const timer = setTimeout(() => { - rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)) + rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`)) }, SHUTDOWN_TIMEOUT_MS) child.on('exit', (code, signal) => { clearTimeout(timer) - log(`daemon exited after signal (code=${code}, signal=${signal})`) + log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`) resolveExit() }) - // Why: SIGTERM is the graceful stop on POSIX (the daemon handles it); - // Windows has no POSIX signal delivery, so Node maps this to process - // termination. Either way the hard assertion is "it stops, no hang". - child.kill('SIGTERM') + void runDaemonRpc( + socketPath, + tokenPath, + protocolVersion, + { + id: 'shutdown-1', + type: 'shutdown', + payload: { killSessions: false } + }, + SHUTDOWN_TIMEOUT_MS + ).catch((error) => { + clearTimeout(timer) + rejectExit(error) + }) }) + if (existsSync(pidPath)) { + throw new Error('daemon left its PID ownership record behind after shutdown') + } + if (process.platform !== 'win32' && existsSync(socketPath)) { + throw new Error('daemon left its endpoint socket behind after shutdown') + } + // The private bind name is consumed by the publish; nothing may linger in the runtime dir. + const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b')) + if (leaked.length > 0) { + throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`) + } log('PASS: daemon booted, served, and shut down under plain Node') } finally { diff --git a/config/scripts/daemon-endpoint-handover-smoke.mjs b/config/scripts/daemon-endpoint-handover-smoke.mjs new file mode 100644 index 00000000000..b6c39a5b55f --- /dev/null +++ b/config/scripts/daemon-endpoint-handover-smoke.mjs @@ -0,0 +1,164 @@ +/** + * Endpoint handover smoke — guards the split-brain failure with real daemon processes. + * + * The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still + * alive used to delete the *replacement's* socket when it finally exited, because libuv + * unlinks the pathname a server bound to with no ownership check. The replacement stayed + * alive hosting PTYs that nothing could reach — terminals that acknowledge input and never + * run it, and that a user cannot fix by restarting the app. + * + * Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur. + * + * Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs + */ +import { fork } from 'node:child_process' +import { connect } from 'node:net' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const repoRoot = resolve(import.meta.dirname, '..', '..') +const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js') +const READY_TIMEOUT_MS = 20_000 +const EXIT_TIMEOUT_MS = 15_000 + +const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`) + +function bootDaemon(tag, dir, socketPath) { + const tokenPath = join(dir, `${tag}.token`) + const pidPath = join(dir, `${tag}.pid`) + const child = fork( + entryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + randomUUID(), + '--entry-path', + entryPath, + '--app-version', + 'endpoint-handover-smoke' + ], + { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, ORCA_USER_DATA_PATH: dir } + } + ) + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString('utf8') + }) + return new Promise((resolveReady, rejectReady) => { + const timer = setTimeout( + () => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)), + READY_TIMEOUT_MS + ) + child.on('message', (msg) => { + if (msg && typeof msg === 'object' && msg.type === 'ready') { + clearTimeout(timer) + resolveReady({ child, tokenPath, pidPath }) + } + }) + child.on('exit', (code) => { + clearTimeout(timer) + rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`)) + }) + }) +} + +function isReachable(socketPath) { + return new Promise((resolveReachable) => { + const socket = connect({ path: socketPath }) + socket.on('connect', () => { + socket.destroy() + resolveReachable(true) + }) + socket.on('error', () => resolveReachable(false)) + }) +} + +function killAndWait(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve() + } + return new Promise((resolveExit, rejectExit) => { + const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS) + child.on('exit', () => { + clearTimeout(timer) + resolveExit() + }) + child.kill('SIGTERM') + }) +} + +async function main() { + if (process.platform === 'win32') { + log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur') + return + } + if (!existsSync(entryPath)) { + throw new Error(`missing ${entryPath} — run \`pnpm build\` first`) + } + + const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-')) + const socketPath = join(dir, 'daemon.sock') + let replaced + let replacement + try { + replaced = await bootDaemon('replaced', dir, socketPath) + const replacedInode = statSync(socketPath).ino + log('daemon A published the endpoint') + + // Reclaim the endpoint name the way daemon replacement does, while A is still alive. + unlinkSync(socketPath) + replacement = await bootDaemon('replacement', dir, socketPath) + const replacementInode = statSync(socketPath).ino + if (replacedInode === replacementInode) { + throw new Error('daemon B did not publish a distinct endpoint') + } + if (!(await isReachable(socketPath))) { + throw new Error('daemon B is not reachable through the canonical endpoint') + } + log('daemon B took over the endpoint and is reachable') + + // A exits long after losing the endpoint. This is the step that used to break B. + await killAndWait(replaced.child) + await new Promise((r) => setTimeout(r, 300)) + + if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) { + throw new Error("daemon A's late exit deleted daemon B's endpoint") + } + if (!(await isReachable(socketPath))) { + throw new Error('daemon B became unreachable after daemon A exited') + } + if (!existsSync(replacement.pidPath)) { + throw new Error("daemon A's late exit removed daemon B's ownership record") + } + if (existsSync(replaced.pidPath)) { + throw new Error('daemon A left its own ownership record behind') + } + + log('PASS: the endpoint owner and the session host stayed the same daemon') + } finally { + for (const daemon of [replaced, replacement]) { + if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) { + try { + daemon.child.kill('SIGKILL') + } catch { + // already gone + } + } + } + rmSync(dir, { recursive: true, force: true }) + } +} + +main().catch((error) => { + console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`) + process.exit(1) +}) diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index dd1d9066a02..a43ca8d05e4 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -509,7 +509,13 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n if (!value || typeof value !== 'object') { return null } - const identity = value as { pid?: unknown; startedAtMs?: unknown; launchNonce?: unknown } + const identity = value as { + pid?: unknown + startedAtMs?: unknown + launchNonce?: unknown + entryPath?: unknown + appVersion?: unknown + } if ( !Number.isSafeInteger(identity.pid) || (identity.pid as number) <= 0 || @@ -524,7 +530,13 @@ function parseDaemonEndpointIdentity(value: unknown): DaemonEndpointIdentity | n return { pid: identity.pid as number, startedAtMs: identity.startedAtMs, - launchNonce: identity.launchNonce + launchNonce: identity.launchNonce, + ...(typeof identity.entryPath === 'string' && identity.entryPath.length > 0 + ? { entryPath: identity.entryPath } + : {}), + ...(typeof identity.appVersion === 'string' && identity.appVersion.length > 0 + ? { appVersion: identity.appVersion } + : {}) } } diff --git a/src/main/daemon/daemon-endpoint-ownership.test.ts b/src/main/daemon/daemon-endpoint-ownership.test.ts new file mode 100644 index 00000000000..8f4e4b82f55 --- /dev/null +++ b/src/main/daemon/daemon-endpoint-ownership.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + linkSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { connect, createServer } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath, publishDaemonPidFile } from './daemon-spawner' +import { readDaemonSocketIdentity } from './daemon-endpoint-ownership' +import type { SubprocessHandle } from './session' + +function connectsTo(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = connect({ path: socketPath }) + socket.on('connect', () => { + socket.destroy() + resolve(true) + }) + socket.on('error', () => resolve(false)) + }) +} + +function createMockSubprocess(): SubprocessHandle { + return { + pid: 55555, + getForegroundProcess: () => null, + write() {}, + resize() {}, + kill() {}, + forceKill() {}, + signal() {}, + onData() {}, + onExit() {}, + dispose() {} + } +} + +describe('daemon endpoint ownership publication', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-endpoint-ownership-')) + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(async () => { + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + it('does not publish an adoptable endpoint before durable ownership succeeds', async () => { + writeFileSync(tokenPath, 'previous-token') + const publishEndpointOwnership = vi.fn(() => { + expect(readFileSync(tokenPath, 'utf8')).toBe('previous-token') + throw Object.assign(new Error('PID record already owned'), { + code: 'EEXIST' + }) + }) + server = new DaemonServer({ + socketPath, + tokenPath, + publishEndpointOwnership, + spawnSubprocess: () => createMockSubprocess() + }) + + await expect(server.start()).rejects.toThrow('PID record already owned') + + expect(publishEndpointOwnership).toHaveBeenCalledOnce() + expect(readFileSync(tokenPath, 'utf8')).toBe('previous-token') + if (process.platform !== 'win32') { + expect(existsSync(socketPath)).toBe(false) + } + }) + + it('rolls back exact PID ownership when token publication fails', async () => { + const pidPath = join(dir, 'daemon.pid') + const launchNonce = 'failed-launch' + mkdirSync(tokenPath) + server = new DaemonServer({ + socketPath, + tokenPath, + pidPath, + launchNonce, + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + startedAtMs: 1_000, + launchNonce + }), + spawnSubprocess: () => createMockSubprocess() + }) + + await expect(server.start()).rejects.toMatchObject({ code: 'EISDIR' }) + + expect(existsSync(pidPath)).toBe(false) + expect(existsSync(tokenPath)).toBe(true) + }) + + it.skipIf(process.platform === 'win32')( + 'keeps a replacement endpoint when the daemon it replaced closes late', + async () => { + // Why: this is the split-brain mechanism. libuv unlinks the pathname a server bound to + // when that server closes, with no ownership check — so a daemon exiting late used to + // delete whichever socket then sat at the canonical path. The replacement stayed alive + // hosting PTYs that no client could reach, which is what "terminals ack but never run" + // looked like from the user's seat. + const replacedPidPath = join(dir, 'replaced.pid') + const replaced = new DaemonServer({ + socketPath, + tokenPath, + pidPath: replacedPidPath, + launchNonce: 'replaced-daemon', + publishEndpointOwnership: () => + publishDaemonPidFile(replacedPidPath, { + pid: process.pid, + startedAtMs: 1_000, + launchNonce: 'replaced-daemon' + }), + spawnSubprocess: () => createMockSubprocess() + }) + await replaced.start() + + // A replacement reclaims the endpoint the way killStaleDaemon does. + unlinkSync(socketPath) + const pidPath = join(dir, 'replacement.pid') + server = new DaemonServer({ + socketPath, + tokenPath, + pidPath, + launchNonce: 'replacement-daemon', + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + startedAtMs: 2_000, + launchNonce: 'replacement-daemon' + }), + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + const replacementIdentity = readDaemonSocketIdentity(socketPath) + + // The daemon that lost the endpoint now exits, long after the handover. + await replaced.shutdown() + + expect(existsSync(socketPath)).toBe(true) + expect(readDaemonSocketIdentity(socketPath)).toEqual(replacementIdentity) + // The replacement is still reachable through the canonical name. + await expect(connectsTo(socketPath)).resolves.toBe(true) + // The late exit also must not remove the replacement's ownership record. + expect(existsSync(pidPath)).toBe(true) + } + ) + + it.skipIf(process.platform === 'win32')( + 'retires a daemon whose endpoint was taken over by another daemon', + async () => { + const pidPath = join(dir, 'daemon.pid') + server = new DaemonServer({ + socketPath, + tokenPath, + pidPath, + launchNonce: 'original-daemon', + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + startedAtMs: 1_000, + launchNonce: 'original-daemon' + }), + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + + const daemon = server as unknown as { + checkEndpointOwnership: () => void + retirementRequested: boolean + } + // An inconclusive or matching probe must never retire a healthy daemon, however often it runs. + daemon.checkEndpointOwnership() + daemon.checkEndpointOwnership() + expect(daemon.retirementRequested).toBe(false) + + // Another daemon takes the endpoint name. + unlinkSync(socketPath) + const usurper = createServer() + const usurperBind = join(dir, '.usurper') + await new Promise((resolve) => usurper.listen(usurperBind, resolve)) + linkSync(usurperBind, socketPath) + unlinkSync(usurperBind) + + try { + // A single observation can land inside a replacement's unlink-then-link gap, so the + // first one must not retire anything. + daemon.checkEndpointOwnership() + expect(daemon.retirementRequested).toBe(false) + + daemon.checkEndpointOwnership() + // Why: retirement drains rather than kills — an orphaned daemon stops being a + // permanent unreachable host without tearing live sessions out from under the user. + expect(daemon.retirementRequested).toBe(true) + } finally { + await new Promise((resolve) => usurper.close(() => resolve())) + try { + unlinkSync(socketPath) + } catch { + // Already gone. + } + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'refuses a second listener when a live daemon socket path was removed', + async () => { + const pidPath = join(dir, 'daemon.pid') + server = new DaemonServer({ + socketPath, + tokenPath, + pidPath, + launchNonce: 'first-launch', + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + startedAtMs: 1_000, + launchNonce: 'first-launch' + }), + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + const firstToken = readFileSync(tokenPath, 'utf8') + unlinkSync(socketPath) + + const duplicate = new DaemonServer({ + socketPath, + tokenPath, + pidPath, + launchNonce: 'second-launch', + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + startedAtMs: 2_000, + launchNonce: 'second-launch' + }), + spawnSubprocess: () => createMockSubprocess() + }) + try { + await expect(duplicate.start()).rejects.toMatchObject({ + code: 'EEXIST' + }) + expect(readFileSync(tokenPath, 'utf8')).toBe(firstToken) + expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toMatchObject({ + startedAtMs: 1_000, + launchNonce: 'first-launch' + }) + } finally { + await duplicate.shutdown() + } + } + ) +}) diff --git a/src/main/daemon/daemon-endpoint-ownership.ts b/src/main/daemon/daemon-endpoint-ownership.ts new file mode 100644 index 00000000000..31e22844793 --- /dev/null +++ b/src/main/daemon/daemon-endpoint-ownership.ts @@ -0,0 +1,169 @@ +/* Ownership of the daemon's canonical endpoint name and of the scratch entries the + rename-claim protocol leaves behind. Kept apart from daemon-spawner so the rule that + decides who may serve on the socket path is readable on its own. */ +import { randomBytes } from 'node:crypto' +import { existsSync, linkSync, readdirSync, renameSync, statSync, unlinkSync } from 'node:fs' +import { dirname, join } from 'node:path' + +/** The exact directory entry a daemon owns. Compared before any endpoint removal. */ +export type DaemonSocketIdentity = { dev: bigint; ino: bigint } + +/** + * A private, same-directory name to bind before publishing the canonical endpoint. + * + * Why: `sockaddr_un.sun_path` caps a Unix socket path at ~104 bytes, so this must not extend + * the canonical path — it replaces the basename with a shorter one, which keeps the bind name + * strictly shorter than the endpoint the caller already requires to fit. + */ +export function getDaemonSocketBindPath(socketPath: string): string { + return join(dirname(socketPath), `.b${randomBytes(5).toString('hex')}`) +} + +/** + * Publishes a bound listener under the canonical endpoint name. + * + * Why: Node/libuv unlinks the pathname a server bound to when that server closes, + * with no ownership check — a daemon exiting late therefore deletes whichever socket + * currently sits at that path, including a live replacement's. Binding a unique path + * and hard-linking it into place instead means libuv only ever unlinks the private + * bind name, and the exclusive link doubles as the kernel-enforced endpoint claim. + */ +export function publishDaemonSocketPath( + boundPath: string, + canonicalPath: string +): DaemonSocketIdentity | null { + if (process.platform === 'win32') { + // Named pipes are not directory entries; the pipe name itself is exclusive. + return null + } + // Why: stat the bound name first — the link shares the inode, so a racing unlink of the + // canonical name cannot erase our identity and leave the endpoint unwatched and uncleanable. + const identity = readDaemonSocketIdentity(boundPath) + try { + linkSync(boundPath, canonicalPath) + } catch (error) { + if (isFileExistsError(error)) { + throw error + } + // Why: a filesystem without hard links must not stop the daemon from starting. Rename + // still moves the bind name out from under libuv, which preserves the property that + // matters most — a late close cannot delete a replacement's endpoint. Exclusivity + // degrades to check-then-act here, which is no weaker than binding the path directly. + if (existsSync(canonicalPath)) { + throw error + } + renameSync(boundPath, canonicalPath) + return identity + } + try { + unlinkSync(boundPath) + } catch { + // Inert: clients resolve the canonical link, and the bind name is unique to us. + } + return identity +} + +function isFileExistsError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST' +} + +export function readDaemonSocketIdentity(socketPath: string): DaemonSocketIdentity | null { + if (process.platform === 'win32') { + return null + } + try { + const stats = statSync(socketPath, { bigint: true }) + return { dev: stats.dev, ino: stats.ino } + } catch { + return null + } +} + +export function daemonSocketIdentityMatches( + a: DaemonSocketIdentity | null, + b: DaemonSocketIdentity | null +): boolean { + return a !== null && b !== null && a.dev === b.dev && a.ino === b.ino +} + +/** 'indeterminate' is deliberately distinct from 'lost': only positive evidence may retire a daemon. */ +export type DaemonEndpointOwnershipState = 'owned' | 'lost' | 'indeterminate' + +export function readDaemonEndpointOwnershipState( + socketPath: string, + owned: DaemonSocketIdentity | null +): DaemonEndpointOwnershipState { + if (process.platform === 'win32' || !owned) { + return 'indeterminate' + } + try { + const stats = statSync(socketPath, { bigint: true }) + return stats.dev === owned.dev && stats.ino === owned.ino ? 'owned' : 'lost' + } catch (error) { + // Why: a stat that failed for any reason other than "the entry is gone" proves nothing. + // Treating EACCES or EIO as lost ownership would retire a perfectly healthy daemon. + return isMissingFileError(error) ? 'lost' : 'indeterminate' + } +} + +function isMissingFileError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' +} + +/** Removes the canonical endpoint name only while it still resolves to our own listener. */ +export function unlinkOwnedDaemonSocketPath( + socketPath: string, + owned: DaemonSocketIdentity | null +): boolean { + if (process.platform === 'win32' || !owned) { + return false + } + if (!daemonSocketIdentityMatches(readDaemonSocketIdentity(socketPath), owned)) { + return false + } + try { + unlinkSync(socketPath) + return true + } catch { + return false + } +} + +const ABANDONED_DAEMON_CLAIM_PATTERN = + /(?:\.(?:cleanup|replace)-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|^\.b[0-9a-f]{10})$/ + +const ABANDONED_DAEMON_CLAIM_MIN_AGE_MS = 60 * 60 * 1000 + +/** + * Reclaims claim/bind scratch names left behind when a rename-claim or bind publish + * could not remove its own temporary entry. Age-gated so a claim in flight is never touched. + */ +export function sweepAbandonedDaemonClaims( + runtimeDir: string, + minAgeMs = ABANDONED_DAEMON_CLAIM_MIN_AGE_MS, + now = Date.now() +): number { + let swept = 0 + let entries: string[] + try { + entries = readdirSync(runtimeDir) + } catch { + return 0 + } + for (const entry of entries) { + if (!ABANDONED_DAEMON_CLAIM_PATTERN.test(entry)) { + continue + } + const claimPath = join(runtimeDir, entry) + try { + if (now - statSync(claimPath).mtimeMs < minAgeMs) { + continue + } + unlinkSync(claimPath) + swept++ + } catch { + // Best-effort; a locked or already-removed claim is retried on a future launch. + } + } + return swept +} diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index 6b02210d72a..8e5ff598df5 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -71,6 +71,30 @@ describe('daemon-entry parseArgs', () => { }) }) + it('parses PID publication metadata from the launcher', () => { + expect( + parseArgs([ + '--socket', + '/tmp/t.sock', + '--token', + '/tmp/t.token', + '--pid-record', + '/tmp/t.pid', + '--launch-nonce', + 'launch-a', + '--entry-path', + '/app/daemon-entry.js', + '--app-version', + '1.2.3' + ]) + ).toMatchObject({ + pidPath: '/tmp/t.pid', + launchNonce: 'launch-a', + entryPath: '/app/daemon-entry.js', + appVersion: '1.2.3' + }) + }) + it('rejects either PID-record ownership argument without its pair', () => { expect(() => parseArgs([ diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index d7655758b67..32628d5275d 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -20,12 +20,15 @@ import { import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity' +import { publishDaemonPidFile } from './daemon-spawner' export type ParsedDaemonArgs = { socketPath: string tokenPath: string pidPath?: string launchNonce?: string + entryPath?: string + appVersion?: string /** GUI-spawned daemons only — headless serve/SSH daemons must survive session loss. */ loginSessionWatch?: boolean /** Optional — absent for adopted old daemons and tests, which log nothing. */ @@ -38,6 +41,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { let logFilePath = '' let pidPath = '' let launchNonce = '' + let entryPath = '' + let appVersion = '' let loginSessionWatch = false for (let i = 0; i < argv.length; i++) { @@ -56,6 +61,12 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { } else if (argv[i] === '--launch-nonce' && argv[i + 1]) { launchNonce = argv[i + 1] i++ + } else if (argv[i] === '--entry-path' && argv[i + 1]) { + entryPath = argv[i + 1] + i++ + } else if (argv[i] === '--app-version' && argv[i + 1]) { + appVersion = argv[i + 1] + i++ } else if (argv[i] === '--login-session-watch') { loginSessionWatch = true } @@ -73,6 +84,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { socketPath, tokenPath, ...(pidPath ? { pidPath, launchNonce } : {}), + ...(entryPath ? { entryPath } : {}), + ...(appVersion ? { appVersion } : {}), ...(loginSessionWatch ? { loginSessionWatch } : {}), ...(logFilePath ? { logFilePath } : {}) } @@ -86,10 +99,18 @@ async function main(): Promise { // an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only. process.stderr.on('error', () => {}) - const { socketPath, tokenPath, pidPath, launchNonce, loginSessionWatch, logFilePath } = parseArgs( - process.argv.slice(2) - ) + const { + socketPath, + tokenPath, + pidPath, + launchNonce, + entryPath, + appVersion, + loginSessionWatch, + logFilePath + } = parseArgs(process.argv.slice(2)) const startedAtMs = Date.now() - process.uptime() * 1000 + const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs) // Fail-open: a broken log path must never block daemon startup. const daemonLog = logFilePath ? createDaemonFileLog(logFilePath) : createNoopDaemonFileLog() daemonLog.log('startup', { protocolVersion: PROTOCOL_VERSION, socketPath }) @@ -236,6 +257,20 @@ async function main(): Promise { ...(pidPath ? { pidPath } : {}), ...(launchNonce ? { launchNonce } : {}), ...(pidPath ? { startedAtMs } : {}), + ...(entryPath ? { entryPath } : {}), + ...(appVersion ? { appVersion } : {}), + ...(pidPath && launchNonce + ? { + publishEndpointOwnership: () => + publishDaemonPidFile(pidPath, { + pid: process.pid, + ...readyIdentity, + ...(entryPath ? { entryPath } : {}), + ...(appVersion ? { appVersion } : {}), + launchNonce + }) + } + : {}), log: daemonLog, preparePtySpawn: runMacosLoginPreflight, ...(deathWatch @@ -260,13 +295,18 @@ async function main(): Promise { daemonLog.log('shutdown', { reason: 'idle' }) daemonLog.close() process.exit(0) + }, + onRpcShutdown: () => { + deathWatch?.stop() + shuttingDown = true + daemonLog.close() + process.exit(0) } }) deathWatch?.start() // Signal readiness to parent via IPC (if available) if (process.send) { - const readyIdentity = await readCurrentDaemonReadyIdentity(startedAtMs) process.send({ type: 'ready', ...readyIdentity }) } daemonLog.log('ready') diff --git a/src/main/daemon/daemon-health-socket-cleanup.test.ts b/src/main/daemon/daemon-health-socket-cleanup.test.ts index 6daa6cdf00b..07294410889 100644 --- a/src/main/daemon/daemon-health-socket-cleanup.test.ts +++ b/src/main/daemon/daemon-health-socket-cleanup.test.ts @@ -79,7 +79,7 @@ describe('daemon health socket listener cleanup', () => { const result = killStaleDaemon(dir, socketPath, tokenPath) await vi.advanceTimersByTimeAsync(500) - await expect(result).resolves.toBe(false) + await expect(result).resolves.toEqual({ killed: false, liveOwnerSurvived: false }) expect(socket.listenerCount('connect')).toBe(0) expect(socket.listenerCount('error')).toBe(0) expect(socket.destroy).toHaveBeenCalledTimes(1) diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index ccfb7d1f0e9..fe459a875c5 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { existsSync, linkSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' -import { createServer, connect, type Server } from 'node:net' +import { createServer, connect, Socket, type Server } from 'node:net' import { DaemonServer } from './daemon-server' import { getDaemonPidPath, serializeDaemonPidFile } from './daemon-spawner' import { @@ -183,7 +184,9 @@ describe('daemon health', () => { writeFileSync(getDaemonPidPath(dir), String(process.pid), { mode: 0o600 }) try { - await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toBe(false) + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toMatchObject({ + killed: false + }) await expect(canConnect(socketPath)).resolves.toBe(true) } finally { await closeServer(server) @@ -430,7 +433,9 @@ describe('killStaleDaemon pid identity guards', () => { // signal is sent. const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) try { - await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toBe(false) + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toMatchObject({ + killed: false + }) const terminationSignals = killSpy.mock.calls.filter( ([, sig]) => sig === 'SIGTERM' || sig === 'SIGKILL' ) @@ -440,3 +445,125 @@ describe('killStaleDaemon pid identity guards', () => { } }) }) + +describe('killStaleDaemon endpoint reclamation', () => { + let dir: string + let socketPath: string + let tokenPath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-health-kill-test-')) + socketPath = daemonTestSocketPath(dir) + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + function listenOnSocketPath(server: Server, path: string): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(path, () => { + server.off('error', reject) + resolve() + }) + }) + } + + it('preserves the pid record and the endpoint when the owner cannot be proven gone', async () => { + if (process.platform === 'win32') { + return + } + + // Why: isDaemonProcess matches on the command line, so a child carrying the daemon + // entry plus this endpoint's paths is adopted as the recorded owner by the first probe. + const child = spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 1000)', 'daemon-entry', socketPath, tokenPath], + { stdio: 'ignore' } + ) + await new Promise((resolve, reject) => { + child.once('spawn', resolve) + child.once('error', reject) + }) + const childPid = child.pid as number + const childExited = new Promise((resolve) => child.once('exit', () => resolve())) + const server = createServer((socket) => socket.end()) + await listenOnSocketPath(server, socketPath) + writeFileSync( + getDaemonPidPath(dir), + serializeDaemonPidFile({ pid: childPid, startedAtMs: null }), + { mode: 0o600 } + ) + + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + // Why: the recorded owner vanishes mid-wait, so the pre-SIGKILL identity re-probe + // fails and only the still-answering endpoint proves a daemon is alive. + const identityBreaker = setTimeout(() => realKill(childPid, 'SIGKILL'), 800) + try { + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toEqual({ + killed: false, + liveOwnerSurvived: true + }) + expect(existsSync(getDaemonPidPath(dir))).toBe(true) + expect(existsSync(socketPath)).toBe(true) + await expect(canConnect(socketPath)).resolves.toBe(true) + } finally { + clearTimeout(identityBreaker) + killSpy.mockRestore() + try { + realKill(childPid, 'SIGKILL') + } catch { + // Already gone. + } + await childExited + await closeServer(server) + } + }) + + it('unlinks a stale endpoint file once connects to it are refused', async () => { + if (process.platform === 'win32') { + return + } + + // Why: hard-linking the bound inode leaves the endpoint name behind after the + // listener closes — exactly the entry a crashed daemon leaves for connect to refuse. + const bindPath = join(dir, '.bstale') + const server = createServer((socket) => socket.end()) + await listenOnSocketPath(server, bindPath) + linkSync(bindPath, socketPath) + await closeServer(server) + + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toEqual({ + killed: false, + liveOwnerSurvived: false + }) + expect(existsSync(socketPath)).toBe(false) + }) + + it('keeps the endpoint file when the connect probe neither connects nor is refused', async () => { + if (process.platform === 'win32') { + return + } + + writeFileSync(socketPath, '') + // A connect that never settles leaves the probe to time out, which is not proof + // the endpoint is dead — net.connect itself cannot be spied on in ESM. + const connectSpy = vi + .spyOn(Socket.prototype, 'connect') + .mockImplementation(function (this: Socket) { + return this + }) + try { + await expect(killStaleDaemon(dir, socketPath, tokenPath)).resolves.toEqual({ + killed: false, + liveOwnerSurvived: false + }) + expect(existsSync(socketPath)).toBe(true) + } finally { + connectSpy.mockRestore() + } + }) +}) diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 71bc7f3279b..952b0c3688b 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -23,6 +23,9 @@ const HEALTH_CHECK_TIMEOUT_MS = 3_000 const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000 const KILL_WAIT_MS = 3_000 const KILL_POLL_MS = 100 +// Why: SIGKILL is delivered on return from an uninterruptible syscall, so confirm the exit +// rather than assume it — but keep the wait short, it only guards the rare wedged case. +const SIGKILL_CONFIRM_WAIT_MS = 1_000 const START_TIME_TOLERANCE_MS = 1_500 // Why: e2e forces the failed-health preserve path without SIGSTOP races — // a stopped daemon also blocks listSessions, so the unhealthy guard cannot @@ -50,10 +53,17 @@ export type ParsedDaemonPid = { bootId: string | null } -function canConnectSocket(socketPath: string): Promise { +/** + * 'connected' — something is listening. 'missing'/'refused' — nothing is, and the endpoint + * name is safe to reclaim. 'unknown' — the probe itself failed (timeout on a loaded host, + * EPERM); the endpoint must be left alone because absence of proof is not proof of death. + */ +type SocketProbeOutcome = 'connected' | 'missing' | 'refused' | 'unknown' + +function probeSocketConnect(socketPath: string): Promise { return new Promise((resolve) => { if (process.platform !== 'win32' && !existsSync(socketPath)) { - resolve(false) + resolve('missing') return } const sock = connect({ path: socketPath }) @@ -63,7 +73,7 @@ function canConnectSocket(socketPath: string): Promise { sock.off('connect', onConnect) sock.off('error', onError) } - const settle = (result: boolean): void => { + const settle = (result: SocketProbeOutcome): void => { if (settled) { return } @@ -72,14 +82,16 @@ function canConnectSocket(socketPath: string): Promise { resolve(result) } const onConnect = (): void => { - settle(true) + settle('connected') sock.destroy() } - const onError = (): void => { - settle(false) + const onError = (error: NodeJS.ErrnoException): void => { + settle( + error.code === 'ECONNREFUSED' ? 'refused' : error.code === 'ENOENT' ? 'missing' : 'unknown' + ) } const timer = setTimeout(() => { - settle(false) + settle('unknown') sock.destroy() }, 500) sock.on('connect', onConnect) @@ -278,7 +290,12 @@ export function getMacDaemonSystemResolverHealth( } // Why: the daemon must report health from inside its own process; // external launchctl bsexec probes can misclassify healthy PTYs. - sock?.write(encodeNdjson({ id: 'resolver-health-1', type: 'systemResolverHealth' })) + sock?.write( + encodeNdjson({ + id: 'resolver-health-1', + type: 'systemResolverHealth' + }) + ) continue } @@ -366,7 +383,10 @@ function getLinuxProcessStartedAtMs(pid: number): number | null { const startTicks = parseLinuxProcStartTicks(stat) const bootTimeSeconds = parseLinuxBootTimeSeconds(readFileSync('/proc/stat', 'utf8')) const ticksPerSecond = Number( - execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8', timeout: 1_000 }).trim() + execFileSync('getconf', ['CLK_TCK'], { + encoding: 'utf8', + timeout: 1_000 + }).trim() ) if ( !Number.isFinite(startTicks) || @@ -657,14 +677,45 @@ export async function isDaemonStaleForCurrentBundle( return true } +function isNoSuchProcessError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + try { + process.kill(pid, 0) + } catch { + return true + } + if (Date.now() >= deadline) { + return false + } + await new Promise((resolve) => setTimeout(resolve, KILL_POLL_MS)) + } +} + +export type StaleDaemonKillOutcome = { + /** A daemon was positively confirmed gone. Drives replacement telemetry. */ + killed: boolean + /** + * We could not prove the recorded daemon is gone. Its PID record and endpoint are left + * intact and the caller must not fork a replacement beside it — that is exactly how two + * daemons end up alive with one owning the socket and the other hosting the sessions. + */ + liveOwnerSurvived: boolean +} + export async function killStaleDaemon( runtimeDir: string, socketPath: string, tokenPath: string, protocolVersion = PROTOCOL_VERSION -): Promise { +): Promise { const pidPath = getDaemonPidPath(runtimeDir, protocolVersion) let killedDaemon = false + let liveOwnerSurvived = false try { const parsedPid = parseDaemonPidFile(readFileSync(pidPath, 'utf8')) if ( @@ -672,7 +723,16 @@ export async function killStaleDaemon( (await isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) ) { const { pid, startedAtMs } = parsedPid - process.kill(pid, 'SIGTERM') + try { + process.kill(pid, 'SIGTERM') + } catch (error) { + // Why: ESRCH means it is already gone. Anything else (EPERM from a daemon owned by + // another user) means it is alive and we cannot stop it — falling through to the + // blanket catch would report "nothing alive" and authorize a duplicate beside it. + if (!isNoSuchProcessError(error)) { + return { killed: false, liveOwnerSurvived: true } + } + } const deadline = Date.now() + KILL_WAIT_MS let exited = false while (Date.now() < deadline) { @@ -690,37 +750,70 @@ export async function killStaleDaemon( // daemon died during the wait. Without this, we'd SIGKILL an unrelated // process that happens to now own the same pid. if (!(await isDaemonProcess(pid, socketPath, tokenPath, startedAtMs))) { - console.warn('[daemon] Skipping SIGKILL for stale daemon: reason=pid_recycled') - exited = true - killedDaemon = true + // Why: a failed identity probe has two causes with opposite correct actions — + // the pid really was recycled (daemon dead, reclaim the endpoint), or the probe + // itself failed under load (`ps` has a 2s timeout). The endpoint settles it: if + // something still answers the socket, a daemon is alive and must be preserved. + if ((await probeSocketConnect(socketPath)) === 'connected') { + console.warn( + '[daemon] Preserving daemon that could not be identified: reason=identity_probe_failed' + ) + liveOwnerSurvived = true + } else { + console.warn('[daemon] Skipping SIGKILL for stale daemon: reason=pid_recycled') + exited = true + } } else { try { process.kill(pid, 'SIGKILL') - exited = true } catch { // Already dead + exited = true + } + // Why: issuing SIGKILL is not proof of death — a process blocked in an + // uninterruptible syscall only dies once it returns. Claiming the kill without + // confirming it is what let the endpoint be reclaimed out from under a daemon + // that was still alive and still serving. + exited = exited || (await waitForProcessExit(pid, SIGKILL_CONFIRM_WAIT_MS)) + if (!exited) { + console.warn('[daemon] Daemon survived SIGKILL: reason=unconfirmed_exit') } } } - killedDaemon = killedDaemon || exited + killedDaemon = exited + // Why: SIGTERM and SIGKILL both failed to produce an exit, so the daemon is still out + // there holding the endpoint. Treat it as the owner rather than racing it. + liveOwnerSurvived = liveOwnerSurvived || !exited } } catch { // PID file missing or process already dead } + if (liveOwnerSurvived) { + // Why: removing the record of a daemon we failed to kill is what let a replacement + // publish its own ownership beside it. Leaving it intact keeps the exclusive PID claim + // held, so a duplicate cannot take the endpoint. + return { killed: killedDaemon, liveOwnerSurvived } + } + try { unlinkSync(pidPath) } catch { // Best-effort } - const socketIsLive = await canConnectSocket(socketPath) - if (process.platform !== 'win32' && existsSync(socketPath) && (killedDaemon || !socketIsLive)) { + const socketOutcome = await probeSocketConnect(socketPath) + // Why: only positive evidence of a dead endpoint authorizes reclaiming the name. A probe + // that merely timed out leaves a live daemon's endpoint in place instead of unlinking it + // and forking a duplicate onto the freed path. + const endpointIsReclaimable = + killedDaemon || socketOutcome === 'refused' || socketOutcome === 'missing' + if (process.platform !== 'win32' && existsSync(socketPath) && endpointIsReclaimable) { try { unlinkSync(socketPath) } catch { // Best-effort } } - return killedDaemon + return { killed: killedDaemon, liveOwnerSurvived } } diff --git a/src/main/daemon/daemon-hello-protocol.ts b/src/main/daemon/daemon-hello-protocol.ts index 4e288e4db9b..8fe0df0ac8a 100644 --- a/src/main/daemon/daemon-hello-protocol.ts +++ b/src/main/daemon/daemon-hello-protocol.ts @@ -10,6 +10,9 @@ export type DaemonEndpointIdentity = { pid: number startedAtMs: number launchNonce: string + /** Optional launch metadata. Absent from daemons that predate it; readers must fall back. */ + entryPath?: string + appVersion?: string } export type HelloResponse = { diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 6f7de545dc4..1ef20756681 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -29,7 +29,11 @@ const { killStaleDaemonMock, getProcessStartedAtMsMock, parseDaemonPidFileMock, + replaceDaemonPidFileMock, + getDaemonCommandLineMock, unlinkOwnedDaemonPidFileMock, + launchedStartedAtMs, + readLaunchedDaemonIdentity, daemonClientMock, spawnerInstances, ensureRunningOverrides, @@ -85,16 +89,37 @@ const { const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy') const getDaemonLaunchIdentityMock = vi.fn(() => 'match') const isDaemonStaleForCurrentBundleMock = vi.fn(() => false) - const killStaleDaemonMock = vi.fn(async () => true) + const killStaleDaemonMock = vi.fn(async () => ({ + killed: true, + liveOwnerSurvived: false + })) const getProcessStartedAtMsMock = vi.fn((): number | null => 1_000_000) const parseDaemonPidFileMock = vi.fn( (): { pid: number; startedAtMs: number | null } | null => null ) + const replaceDaemonPidFileMock = vi.fn(() => true) + const getDaemonCommandLineMock = vi.fn(async (_pid: number): Promise => null) const unlinkOwnedDaemonPidFileMock = vi.fn(() => true) + const launchedStartedAtMs = { current: 1_000_000 } + + const readLaunchedDaemonIdentity = () => { + const args = forkMock.mock.calls.at(-1)?.[1] + const child = forkMock.mock.results.at(-1)?.value as { pid?: unknown } | undefined + const launchNonceIndex = Array.isArray(args) ? args.indexOf('--launch-nonce') : -1 + const launchNonce = launchNonceIndex >= 0 ? args[launchNonceIndex + 1] : undefined + return typeof child?.pid === 'number' && typeof launchNonce === 'string' + ? { + pid: child.pid, + startedAtMs: launchedStartedAtMs.current, + launchNonce + } + : null + } const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } @@ -170,7 +195,11 @@ const { killStaleDaemonMock, getProcessStartedAtMsMock, parseDaemonPidFileMock, + replaceDaemonPidFileMock, + getDaemonCommandLineMock, unlinkOwnedDaemonPidFileMock, + launchedStartedAtMs, + readLaunchedDaemonIdentity, daemonClientMock, spawnerInstances, ensureRunningOverrides, @@ -250,6 +279,7 @@ vi.mock('net', () => ({ connect: netConnectMock })) vi.mock('./daemon-health', () => ({ checkDaemonHealth: checkDaemonHealthMock, + getDaemonCommandLine: getDaemonCommandLineMock, getDaemonLaunchIdentity: getDaemonLaunchIdentityMock, getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock, healthCheckDaemon: healthCheckDaemonMock, @@ -290,7 +320,10 @@ vi.mock('./daemon-spawner', () => ({ const result = await override() const releaseAdoptionLease = vi.fn() adoptionLeaseReleases.push(releaseAdoptionLease) - this.handle = { releaseAdoptionLease, shutdown: vi.fn(async () => {}) } + this.handle = { + releaseAdoptionLease, + shutdown: vi.fn(async () => {}) + } if (result.mode) { this.handle.mode = result.mode } @@ -321,6 +354,8 @@ vi.mock('./daemon-spawner', () => ({ getDaemonPidPath: (_dir: string, version?: number) => `/fake/daemon/daemon-v${version ?? PROTOCOL_VERSION}.pid`, serializeDaemonPidFile: (obj: unknown) => JSON.stringify(obj), + replaceDaemonPidFile: replaceDaemonPidFileMock, + sweepAbandonedDaemonClaims: vi.fn(() => 0), unlinkOwnedDaemonPidFile: unlinkOwnedDaemonPidFileMock })) @@ -419,7 +454,10 @@ async function importFresh() { // mockReset (not mockClear) also drops an unconsumed *Once queue, so a test that bails early // can't leak a queued false into the next test's confirmedReplacement gate. killStaleDaemonMock.mockReset() - killStaleDaemonMock.mockResolvedValue(true) + killStaleDaemonMock.mockResolvedValue({ + killed: true, + liveOwnerSurvived: false + }) getAppPathMock.mockReset() getAppPathMock.mockReturnValue('/fake/app') forkMock.mockReset() @@ -429,6 +467,7 @@ async function importFresh() { daemonClientMock.mockImplementation(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(async () => ({ sessions: [] })), disconnect: vi.fn() } @@ -444,6 +483,7 @@ async function importFresh() { parseDaemonPidFileMock.mockReturnValue(null) unlinkOwnedDaemonPidFileMock.mockReset() unlinkOwnedDaemonPidFileMock.mockReturnValue(true) + launchedStartedAtMs.current = 1_000_000 getProcessStartedAtMsMock.mockReset() getProcessStartedAtMsMock.mockReturnValue(1_000_000) // Why: import after resetModules so module-level spawner/adapter/restartInFlight start fresh — needed to test first-init and the coalescer. @@ -486,7 +526,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { probeSocketExistsMock.mockReturnValue(false) netConnectMock.mockReset() netConnectMock.mockImplementation(() => { - const handlers: Record void)[]> = { connect: [], error: [] } + const handlers: Record void)[]> = { + connect: [], + error: [] + } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) @@ -601,7 +644,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(spawnerInstances[0].ensureRunning).toHaveBeenCalledTimes(1) abortController.abort() - resolveEnsureRunning({ socketPath: '/fake/socket-late', tokenPath: '/fake/token-late' }) + resolveEnsureRunning({ + socketPath: '/fake/socket-late', + tokenPath: '/fake/token-late' + }) await started expect(adapterInstances).toHaveLength(1) @@ -685,7 +731,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const result = await provider!.spawn({ cols: 80, rows: 24 }) expect(result.id).toBe('local-fallback-pty') - expect(localFallbackProvider.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 }) + expect(localFallbackProvider.spawn).toHaveBeenCalledWith({ + cols: 80, + rows: 24 + }) expect(adapterInstances[0].listProcesses).toHaveBeenCalled() }) @@ -888,7 +937,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ensureRunningOverrides.push(async () => { await outgoingRespawn?.('daemon_died') respawnedMidRestart = true - return { socketPath: '/fake/restarted-socket', tokenPath: '/fake/restarted-token' } + return { + socketPath: '/fake/restarted-socket', + tokenPath: '/fake/restarted-token' + } }) await mod.restartDaemon() @@ -909,7 +961,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ensureRunningOverrides.push(async () => { await restartedRespawn?.('daemon_died') respawnedMidSecondRestart = true - return { socketPath: '/fake/restarted-socket-2', tokenPath: '/fake/restarted-token-2' } + return { + socketPath: '/fake/restarted-socket-2', + tokenPath: '/fake/restarted-token-2' + } }) await mod.restartDaemon() @@ -1060,7 +1115,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { // Make probeSocket return true: needs both the fs.existsSync proxy AND net.connect resolving "alive". probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementationOnce(() => { - const handlers: Record void)[]> = { connect: [], error: [] } + const handlers: Record void)[]> = { + connect: [], + error: [] + } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) @@ -1081,7 +1139,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { // The shutdown RPC must have been issued with killSessions=true. expect(ensureConnectedMock).toHaveBeenCalled() - expect(requestMock).toHaveBeenCalledWith('shutdown', { killSessions: true }) + expect(requestMock).toHaveBeenCalledWith('shutdown', { + killSessions: true + }) // The fallback killStaleDaemon must NOT fire when the RPC path worked. expect(killStaleDaemonMock).not.toHaveBeenCalled() }) @@ -1119,7 +1179,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await vi.advanceTimersByTimeAsync(1000) - await expect(cleanup).resolves.toEqual({ cleaned: false, killedCount: 0 }) + await expect(cleanup).resolves.toEqual({ + cleaned: false, + killedCount: 0 + }) expect(socket.destroy).toHaveBeenCalledTimes(1) expect(socket.listenerCount('connect')).toBe(0) expect(socket.listenerCount('error')).toBe(0) @@ -1182,7 +1245,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { it('respawns instead of reusing a healthy daemon launched from another app path', async () => { const mod = await importFresh() - await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) + await mod.initDaemonPtyProvider(undefined, { + macosLoginSessionWatch: true + }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, @@ -1279,6 +1344,192 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(disconnect).toHaveBeenCalledOnce() }) + it('repairs a stale PID record to the authenticated socket owner before adoption', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + const endpointIdentity = { + pid: 101, + startedAtMs: 1_000_000, + launchNonce: 'socket-owner' + } + daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { + return { + ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(() => endpointIdentity), + request: vi.fn(), + disconnect: vi.fn() + } + }) + readFileSyncMock.mockReturnValueOnce( + JSON.stringify({ + pid: 202, + startedAtMs: 2_000_000, + launchNonce: 'stale-owner' + }) + ) + readFileSyncMock.mockReturnValueOnce(JSON.stringify(endpointIdentity)) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise }> + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const handle = await launcher( + '/fake/socket', + '/fake/token', + '/fake/daemon.pid', + 'unused-new-launch' + ) + + expect(replaceDaemonPidFileMock).toHaveBeenCalledWith('/fake/daemon.pid', endpointIdentity) + expect(warn).toHaveBeenCalledWith( + '[daemon] Repaired daemon PID ownership to match the authenticated endpoint' + ) + handle.releaseAdoptionLease?.() + } finally { + warn.mockRestore() + } + }) + + it('republishes the endpoint owner launch metadata into a repaired PID record', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + // Why: the mismatched record's metadata belongs to another daemon, so freshness and + // host-pinning fields must come from the authenticated owner — a record without + // appVersion reads as a permanently stale bundle and gets needlessly replaced. The + // install path deliberately contains a space: re-parsing it out of a space-joined + // command line truncated it and made a healthy daemon look like a different app path. + const endpointIdentity = { + pid: 101, + startedAtMs: 1_000_000, + launchNonce: 'socket-owner', + entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js', + appVersion: '9.9.9' + } + daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { + return { + ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(() => endpointIdentity), + request: vi.fn(), + disconnect: vi.fn() + } + }) + readFileSyncMock.mockReturnValueOnce( + JSON.stringify({ + pid: 202, + startedAtMs: 2_000_000, + launchNonce: 'stale-owner' + }) + ) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise }> + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused') + + expect(replaceDaemonPidFileMock).toHaveBeenCalledWith('/fake/daemon.pid', { + pid: 101, + startedAtMs: 1_000_000, + launchNonce: 'socket-owner', + entryPath: '/Applications/Orca 2.app/Contents/out/main/daemon-entry.js', + appVersion: '9.9.9' + }) + handle.releaseAdoptionLease?.() + } finally { + warn.mockRestore() + } + }) + + it('adopts the authenticated endpoint even when the PID record cannot be repaired', async () => { + const mod = await importFresh() + await mod.initDaemonPtyProvider() + const endpointIdentity = { + pid: 101, + startedAtMs: 1_000_000, + launchNonce: 'socket-owner' + } + daemonClientMock.mockImplementationOnce(function MockAdoptionClient() { + return { + ensureConnected: vi.fn(async () => {}), + getDaemonIdentity: vi.fn(() => endpointIdentity), + request: vi.fn(), + disconnect: vi.fn() + } + }) + readFileSyncMock.mockReturnValueOnce( + JSON.stringify({ + pid: 202, + startedAtMs: 2_000_000, + launchNonce: 'stale-owner' + }) + ) + // Why: fail open. Losing every persistent terminal because a pid file write failed is a + // far worse outcome than a record that disagrees with the endpoint. + replaceDaemonPidFileMock.mockReturnValueOnce(false) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ releaseAdoptionLease?(): void; shutdown(): Promise }> + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const forkCallsBefore = forkMock.mock.calls.length + + try { + const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'unused') + + expect(handle).toBeDefined() + // The healthy daemon is adopted, not replaced. + expect(forkMock.mock.calls.length).toBe(forkCallsBefore) + expect(warn).toHaveBeenCalledWith( + '[daemon] Could not repair daemon PID ownership; adopting the authenticated endpoint anyway' + ) + handle.releaseAdoptionLease?.() + } finally { + warn.mockRestore() + } + }) + + it('degrades instead of forking beside a daemon that could not be confirmed stopped', async () => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + const forkCallsBefore = forkMock.mock.calls.length + // Why: forking beside a survivor is exactly how the endpoint owner and the session host + // diverge — one daemon answers the socket while another hosts the visible terminals. But + // refusing outright would leave the user with no daemon at all, and something demonstrably + // still answers the endpoint, so adopt it degraded: live sessions keep working. + killStaleDaemonMock.mockResolvedValueOnce({ + killed: false, + liveOwnerSurvived: true + }) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ mode?: string; releaseAdoptionLease?(): void }> + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + const handle = await launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new') + + expect(handle.mode).toBe('degraded-new-pty-fallback') + expect(forkMock.mock.calls.length).toBe(forkCallsBefore) + handle.releaseAdoptionLease?.() + } finally { + warn.mockRestore() + } + }) + it('disconnects every temporary client when healthy adoption fails', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1590,7 +1841,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const mod = await importFresh() await mod.initDaemonPtyProvider() checkDaemonHealthMock.mockResolvedValue('unreachable') - killStaleDaemonMock.mockResolvedValueOnce(false) + killStaleDaemonMock.mockResolvedValueOnce({ + killed: false, + liveOwnerSurvived: false + }) forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) @@ -1618,7 +1872,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { // The daemon is gone before the launcher looks: nothing answers, nothing left to kill. checkDaemonHealthMock.mockResolvedValue('unreachable') - killStaleDaemonMock.mockResolvedValueOnce(false).mockResolvedValueOnce(false) + killStaleDaemonMock + .mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false }) + .mockResolvedValueOnce({ killed: false, liveOwnerSurvived: false }) forkMock.mockImplementationOnce(() => { throw new Error('stop after replacement decision') }) @@ -1716,12 +1972,18 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { trackDaemonReplacedMock.mockClear() getDaemonLaunchIdentityMock.mockReturnValueOnce('mismatch') - killStaleDaemonMock.mockResolvedValueOnce(false) + killStaleDaemonMock.mockResolvedValueOnce({ + killed: false, + liveOwnerSurvived: false + }) // The daemon answers cleanup's liveness probe, then the endpoint goes away so the self-shutdown // wait succeeds and cleanup reports cleaned:true. probeSocketExistsMock.mockReturnValue(true) netConnectMock.mockImplementationOnce(() => { - const handlers: Record void)[]> = { connect: [], error: [] } + const handlers: Record void)[]> = { + connect: [], + error: [] + } return { on(event: string, cb: () => void) { handlers[event]?.push(cb) @@ -1810,28 +2072,112 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() - const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? [] - expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`) - expect(JSON.parse(pidContents as string)).toEqual({ - pid: 12345, - startedAtMs: 1_000_000, - linuxStartTicks: '4242', - bootId: 'boot-a', - entryPath: FAKE_DAEMON_ENTRY_PATH, - appVersion: '1.2.3', - launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) - }) - expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' }) const launchArgs = forkMock.mock.calls.at(-1)?.[1] as string[] const launchNonceIndex = launchArgs.indexOf('--launch-nonce') expect(launchArgs).toEqual( expect.arrayContaining([ '--pid-record', `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, - '--launch-nonce' + '--launch-nonce', + expect.stringMatching(/^[0-9a-f-]{36}$/), + '--entry-path', + FAKE_DAEMON_ENTRY_PATH, + '--app-version', + '1.2.3' ]) ) - expect(launchArgs[launchNonceIndex + 1]).toBe(JSON.parse(pidContents as string).launchNonce) + expect(launchArgs[launchNonceIndex + 1]).toMatch(/^[0-9a-f-]{36}$/) + }) + + it.each([ + [ + 'changed endpoint identity', + () => ({ + getDaemonIdentity: vi.fn(() => ({ + pid: 999, + startedAtMs: 900_000, + launchNonce: 'stale-launch' + })) + }) + ], + ['missing endpoint identity reader', () => ({})] + ])('rejects and reaps a child with %s before adoption', async (_case, identityMethods) => { + const mod = await importFresh() + checkDaemonHealthMock.mockResolvedValue('unreachable') + await mod.initDaemonPtyProvider() + function basicClient() { + return { + ensureConnected: vi.fn(async () => {}), + request: vi.fn(async () => ({ sessions: [] })), + disconnect: vi.fn() + } + } + daemonClientMock.mockImplementationOnce(basicClient) + daemonClientMock.mockImplementationOnce(basicClient) + daemonClientMock.mockImplementationOnce(function postReadyClient() { + return { + ...basicClient(), + ...identityMethods() + } + }) + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + on(event: string, callback: (arg?: unknown) => void) { + handlers[event]?.push(callback) + if (event === 'message') { + queueMicrotask(() => callback({ type: 'ready', startedAtMs: 1_000_000 })) + } + return this + }, + off(event: string, callback: (arg?: unknown) => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] + return this + }, + kill: vi.fn(() => true), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + queueMicrotask(() => { + child.exitCode = 0 + for (const callback of handlers.exit.slice()) { + callback(0) + } + }) + return true + }) + forkMock.mockReturnValueOnce(child) + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string, + pidPath?: string, + launchNonce?: string + ) => Promise<{ shutdown(): Promise }> + + try { + await expect( + launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-new') + ).rejects.toThrow('Daemon endpoint ownership changed during startup') + + expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM') + expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith( + '/fake/daemon.pid', + 12345, + 'launch-new' + ) + } finally { + kill.mockRestore() + } }) it('keeps a live PID record after adoption failure and removes it on exact child exit', async () => { @@ -1895,11 +2241,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { launcher('/fake/socket', '/fake/token', '/fake/daemon.pid', 'launch-delayed') ).rejects.toThrow('adoption unavailable') - expect(writeFileSyncMock).toHaveBeenCalledWith( - '/fake/daemon.pid', - expect.stringContaining('launch-delayed'), - { mode: 0o600, flag: 'wx' } - ) + expect(writeFileSyncMock).not.toHaveBeenCalled() expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled() expect(adoptionDisconnects.at(-1)).toHaveBeenCalledOnce() @@ -2003,6 +2345,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL') expect(child.disconnect).toHaveBeenCalledOnce() expect(child.unref).toHaveBeenCalledOnce() + expect(unlinkOwnedDaemonPidFileMock).not.toHaveBeenCalled() } finally { kill.mockRestore() vi.useRealTimers() @@ -2010,7 +2353,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { }) it('surfaces non-ESRCH startup termination errors and releases IPC', async () => { - const signalError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + const signalError = Object.assign(new Error('operation not permitted'), { + code: 'EPERM' + }) const kill = vi.spyOn(process, 'kill').mockImplementation(() => { throw signalError }) @@ -2045,7 +2390,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(error).toBeInstanceOf(AggregateError) expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), + expect.objectContaining({ + message: 'Daemon readiness identity is incomplete' + }), signalError ]) expect(child.disconnect).toHaveBeenCalledOnce() @@ -2100,8 +2447,12 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(error).toBeInstanceOf(AggregateError) expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), - expect.objectContaining({ message: 'Daemon did not exit after SIGKILL' }) + expect.objectContaining({ + message: 'Daemon readiness identity is incomplete' + }), + expect.objectContaining({ + message: 'Daemon did not exit after SIGKILL' + }) ]) expect(kill).toHaveBeenNthCalledWith(1, 12345, 'SIGTERM') expect(kill).toHaveBeenNthCalledWith(2, 12345, 'SIGKILL') @@ -2116,7 +2467,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } }) - it('kills and rejects a daemon when exclusive PID publication fails', async () => { + it('leaves exclusive PID publication to the daemon child', async () => { const mod = await importFresh() checkDaemonHealthMock.mockResolvedValue('unreachable') await mod.initDaemonPtyProvider() @@ -2125,9 +2476,6 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { socketPath: string, tokenPath: string ) => Promise<{ shutdown(): Promise }> - const kill = vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error('already exited'), { code: 'ESRCH' }) - }) const child = { pid: 12345, on(event: string, cb: (arg?: unknown) => void) { @@ -2140,27 +2488,21 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { disconnect: vi.fn(), unref: vi.fn() } - const publicationError = Object.assign(new Error('PID record already exists'), { - code: 'EEXIST' - }) - writeFileSyncMock.mockImplementationOnce(() => { - throw publicationError - }) forkMock.mockReturnValueOnce(child) - try { - await expect(launcher('/fake/socket', '/fake/token')).rejects.toBe(publicationError) - expect(writeFileSyncMock).toHaveBeenCalledWith( + await launcher('/fake/socket', '/fake/token') + + expect(writeFileSyncMock).not.toHaveBeenCalled() + expect(forkMock.mock.calls.at(-1)?.[1]).toEqual( + expect.arrayContaining([ + '--pid-record', `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, - expect.any(String), - { mode: 0o600, flag: 'wx' } - ) - expect(kill).toHaveBeenCalledWith(12345, 'SIGTERM') - expect(child.disconnect).not.toHaveBeenCalled() - expect(child.unref).toHaveBeenCalledOnce() - } finally { - kill.mockRestore() - } + '--entry-path', + FAKE_DAEMON_ENTRY_PATH, + '--app-version', + '1.2.3' + ]) + ) }) it('removes detached daemon startup listeners after startup error', async () => { @@ -2215,7 +2557,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const launcher = spawnerInstances[0].launcher as ( socketPath: string, - tokenPath: string + tokenPath: string, + pidPath?: string, + launchNonce?: string ) => Promise<{ shutdown(): Promise }> const handlers: Record void)[]> = { message: [], @@ -2269,11 +2613,21 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { } forkMock.mockReturnValueOnce(child) - const error = await launcher('/fake/socket', '/fake/token').catch((err: Error) => err) + const error = await launcher( + '/fake/socket', + '/fake/token', + '/fake/daemon.pid', + 'failed-launch' + ).catch((err: Error) => err) expect(error).toBeInstanceOf(Error) expect((error as Error).message).toMatch(/Cannot find module 'electron'/) expect((error as Error).message).toMatch(/Daemon stderr \(tail\)/) + expect(unlinkOwnedDaemonPidFileMock).toHaveBeenCalledWith( + '/fake/daemon.pid', + 4321, + 'failed-launch' + ) // Why: release the piped stderr so the detached daemon can't keep the parent event loop alive after failure. expect(stderrDestroy).toHaveBeenCalled() }) @@ -2389,7 +2743,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const launcher = spawnerInstances[0].launcher as ( socketPath: string, tokenPath: string - ) => Promise<{ mode?: 'degraded-new-pty-fallback'; shutdown(): Promise }> + ) => Promise<{ + mode?: 'degraded-new-pty-fallback' + shutdown(): Promise + }> checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy') const handle = await launcher('/fake/socket', '/fake/token') @@ -2542,7 +2899,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { daemonClientMock.mockImplementationOnce(function MockDaemonClient() { return { ensureConnected: vi.fn(async () => {}), - request: vi.fn(async () => ({ sessions: [{ sessionId: 'wt-1@@live', isAlive: true }] })), + request: vi.fn(async () => ({ + sessions: [{ sessionId: 'wt-1@@live', isAlive: true }] + })), disconnect: vi.fn() } }) @@ -2583,6 +2942,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { throw new Error('Hello response timed out') } }), + getDaemonIdentity: vi.fn(readLaunchedDaemonIdentity), request: vi.fn(), disconnect: vi.fn() } @@ -2760,8 +3120,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).not.toHaveBeenCalled() }) - it('writes the daemon self-reported start time to the pid file when the OS query returns null', async () => { - // Why: getProcessStartedAtMs has no cheap Windows impl, so the pid-recycling guard uses the ready-message fallback. + it('accepts the daemon self-reported start time when the OS query returns null', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -2771,6 +3130,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { ) => Promise<{ shutdown(): Promise }> checkDaemonHealthMock.mockResolvedValueOnce('unreachable') getProcessStartedAtMsMock.mockReturnValue(null) + launchedStartedAtMs.current = 1_700_000_123_456 forkMock.mockImplementationOnce(() => ({ pid: 12345, on(event: string, cb: (arg?: unknown) => void) { @@ -2788,16 +3148,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') - const [pidPath, pidContents, pidOptions] = writeFileSyncMock.mock.calls.at(-1) ?? [] - expect(pidPath).toBe(`/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`) - expect(JSON.parse(pidContents as string)).toEqual({ - pid: 12345, - startedAtMs: 1_700_000_123_456, - entryPath: FAKE_DAEMON_ENTRY_PATH, - appVersion: '1.2.3', - launchNonce: expect.stringMatching(/^[0-9a-f-]{36}$/) - }) - expect(pidOptions).toEqual({ mode: 0o600, flag: 'wx' }) + expect(writeFileSyncMock).not.toHaveBeenCalled() }) it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => { @@ -2805,7 +3156,10 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { const mod = await importFresh() readFileSyncMock.mockReturnValue('{"pid":123}') // process.pid is guaranteed alive, so the liveness probe succeeds. - parseDaemonPidFileMock.mockReturnValue({ pid: process.pid, startedAtMs: null }) + parseDaemonPidFileMock.mockReturnValue({ + pid: process.pid, + startedAtMs: null + }) await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index de040bb8efc..9a3339fc54e 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -3,7 +3,7 @@ restart, teardown); the "swap the provider atomically" invariant keeps restart + import { join } from 'node:path' import { randomUUID } from 'node:crypto' import { app } from 'electron' -import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs' import { fork, type ChildProcess } from 'node:child_process' import { connect } from 'node:net' import { @@ -11,11 +11,13 @@ import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath, - serializeDaemonPidFile, + replaceDaemonPidFile, unlinkOwnedDaemonPidFile, type DaemonLauncher, + type DaemonPidFile, type DaemonProcessHandle } from './daemon-spawner' +import { sweepAbandonedDaemonClaims } from './daemon-endpoint-ownership' import { DaemonPtyAdapter, type DaemonRespawnReason } from './daemon-pty-adapter' import { DaemonPtyRouter } from './daemon-pty-router' import { DaemonClient } from './client' @@ -53,12 +55,16 @@ import { confirmSeededClaudeLivePtys, hasSeededUnconfirmedClaudePtys } from '../claude-accounts/live-pty-gate' -import { parseDaemonReadyIdentity } from './daemon-ready-identity' +import { parseDaemonReadyIdentity, readDaemonProcessIncarnation } from './daemon-ready-identity' +import type { DaemonEndpointIdentity } from './daemon-hello-protocol' // Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start. function logDaemonMilestone(event: string, details: Record = {}): void { if (isStartupDiagnosticsEnabled()) { - logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details }) + logStartupDiagnostic(event, { + t: Math.round(performance.now()), + ...details + }) } } @@ -68,6 +74,24 @@ const DAEMON_SELF_SHUTDOWN_WAIT_MS = 5_000 const DAEMON_CHILD_TERMINATION_GRACE_MS = 5_000 const DAEMON_CHILD_FORCE_EXIT_WAIT_MS = 1_000 +class DaemonEndpointOwnershipError extends Error {} + +/** + * The one method the ownership checks need from a connected client. + * + * Why: naming the capability instead of casting DaemonClient to Partial keeps a rename from + * silently turning the fence into a no-op — the compiler now rejects a client without it. + */ +type DaemonEndpointIdentityReader = { + getDaemonIdentity?: () => DaemonEndpointIdentity | null +} + +function readDaemonEndpointIdentity( + client: DaemonEndpointIdentityReader +): DaemonEndpointIdentity | null { + return client.getDaemonIdentity?.() ?? null +} + let spawner: DaemonSpawner | null = null type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider @@ -184,11 +208,25 @@ async function holdDaemonAdoptionLease( handle: DaemonProcessHandle, socketPath: string, tokenPath: string, - connectedClient?: DaemonClient + connectedClient?: DaemonClient, + expectedIdentity?: DaemonEndpointIdentity, + pidPath?: string ): Promise { const client = connectedClient ?? new DaemonClient({ socketPath, tokenPath }) try { await client.ensureConnected() + if (expectedIdentity) { + const actualIdentity = readDaemonEndpointIdentity(client) + if ( + !actualIdentity || + actualIdentity.pid !== expectedIdentity.pid || + actualIdentity.startedAtMs !== expectedIdentity.startedAtMs || + actualIdentity.launchNonce !== expectedIdentity.launchNonce + ) { + throw new DaemonEndpointOwnershipError('Daemon endpoint ownership changed during startup') + } + } + await reconcileDaemonPidOwnership(client, pidPath) } catch (error) { client.disconnect() throw error @@ -197,6 +235,76 @@ async function holdDaemonAdoptionLease( return handle } +async function reconcileDaemonPidOwnership( + client: DaemonEndpointIdentityReader, + pidPath?: string +): Promise { + const endpointIdentity = readDaemonEndpointIdentity(client) + if (!pidPath || !endpointIdentity || pidRecordMatchesEndpoint(pidPath, endpointIdentity)) { + return + } + // Why: the mismatched record's metadata describes a different daemon and must not be copied + // onto this one. Re-derive it from the authenticated owner instead, so the repaired record + // keeps the fields freshness, host pinning and pid-recycle detection depend on. + const { pid, startedAtMs, launchNonce } = endpointIdentity + const ownerMetadata = await readDaemonOwnerMetadata(endpointIdentity) + if (!replaceDaemonPidFile(pidPath, { pid, startedAtMs, launchNonce, ...ownerMetadata })) { + // Why: fail open. A record that disagrees with the endpoint is a diagnosable nuisance; + // abandoning a healthy adoptable daemon over a failed file write costs the user every + // persistent terminal on the machine. + console.warn( + '[daemon] Could not repair daemon PID ownership; adopting the authenticated endpoint anyway' + ) + return + } + console.warn('[daemon] Repaired daemon PID ownership to match the authenticated endpoint') +} + +/** + * Recovers the owning daemon's launch metadata. + * + * Why: `entryPath`/`appVersion` gate bundle-freshness and (on Windows) which relocated daemon + * host directories are pinned against pruning, and the Linux pair gates pid-recycle detection. + * Publishing a repaired record without them makes a healthy daemon look permanently stale. + * The values come from the authenticated hello rather than the owner's command line: a command + * line is a single space-joined string, so any install path containing a space (`C:\Program + * Files\...`, `/Applications/Orca 2.app/...`) cannot be split back into argv unambiguously. + */ +async function readDaemonOwnerMetadata( + identity: DaemonEndpointIdentity +): Promise> { + const metadata: Partial = {} + if (identity.entryPath) { + metadata.entryPath = identity.entryPath + } + if (identity.appVersion) { + metadata.appVersion = identity.appVersion + } + const incarnation = await readDaemonProcessIncarnation(identity.pid) + if (incarnation) { + metadata.linuxStartTicks = incarnation.linuxStartTicks + metadata.bootId = incarnation.bootId + } + return metadata +} + +function pidRecordMatchesEndpoint(pidPath: string, identity: DaemonEndpointIdentity): boolean { + try { + const record = JSON.parse(readFileSync(pidPath, 'utf8')) as { + pid?: unknown + startedAtMs?: unknown + launchNonce?: unknown + } + return ( + record.pid === identity.pid && + record.startedAtMs === identity.startedAtMs && + record.launchNonce === identity.launchNonce + ) + } catch { + return false + } +} + function releaseDaemonAdoptionLease(handle: DaemonProcessHandle | null): void { takeDaemonAdoptionLeaseRelease(handle)?.() } @@ -350,10 +458,14 @@ function createOutOfProcessLauncher( } | undefined let confirmedReplacement = false - let adoptionClient: DaemonClient | null = new DaemonClient({ socketPath, tokenPath }) + let adoptionClient: DaemonClient | null = new DaemonClient({ + socketPath, + tokenPath + }) try { // Why: acquire the full pair before control-only probes so an expired inherited deadline can't fire in the probe-to-adoption gap. await adoptionClient.ensureConnected() + await reconcileDaemonPidOwnership(adoptionClient, pidPath) } catch { adoptionClient.disconnect() adoptionClient = null @@ -367,7 +479,9 @@ function createOutOfProcessLauncher( createPreservedDaemonHandle(runtimeDir, PROTOCOL_VERSION, mode), socketPath, tokenPath, - connectedClient + connectedClient, + undefined, + pidPath ) } try { @@ -385,7 +499,10 @@ function createOutOfProcessLauncher( return preserveDaemon() } console.warn('[daemon] Replacing daemon with unavailable macOS system resolver') - pendingReplacement = { reason: 'unhealthy_resolver', liveSessionCount } + pendingReplacement = { + reason: 'unhealthy_resolver', + liveSessionCount + } confirmedReplacement = (await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)) .cleaned } else { @@ -470,14 +587,34 @@ function createOutOfProcessLauncher( } // Why: unlike the log above, telemetry gates on confirmedReplacement below — the // post-kill truth — so a cold start that killed nothing never reports a replacement. - pendingReplacement = { reason: 'failed_health_check', liveSessionCount } + pendingReplacement = { + reason: 'failed_health_check', + liveSessionCount + } } // Why: a raw socket can outlive a broken daemon; kill by PID before respawn so the new daemon doesn't race the stale one. adoptionClient?.disconnect() adoptionClient = null - confirmedReplacement = - (await killStaleDaemon(runtimeDir, socketPath, tokenPath)) || confirmedReplacement + const killOutcome = await killStaleDaemon(runtimeDir, socketPath, tokenPath) + if (killOutcome.liveOwnerSurvived) { + // Why: forking beside a daemon we could not prove dead is precisely how the endpoint + // owner and the session host diverge. But refusing outright would leave the user with + // no daemon at all, and we have just proved something still answers the endpoint — + // so adopt it in degraded mode: existing sessions keep working, new PTYs run locally. + console.warn( + '[daemon] DEGRADED MODE: adopting a daemon that could not be confirmed stopped. Existing sessions keep working; fresh terminals run on the local provider WITHOUT daemon persistence until you restart the daemon (Manage Sessions → Restart).' + ) + try { + return await preserveDaemon('degraded-new-pty-fallback') + } catch { + // It died between the probe and the adoption; the endpoint is genuinely free now. + throw new DaemonEndpointOwnershipError( + 'Daemon replacement aborted: the existing daemon could not be confirmed stopped' + ) + } + } + confirmedReplacement = killOutcome.killed || confirmedReplacement // Why: rank by how well each reason is evidenced. A confirmed kill whose reason positively // identified the daemon outranks the attribution, so a stale bundle caught here is not billed // to the resolver. failed_health_check is the residual "couldn't tell" bucket though — it also @@ -514,6 +651,10 @@ function createOutOfProcessLauncher( pidPath, '--launch-nonce', launchNonce, + '--entry-path', + entryPath, + '--app-version', + app.getVersion(), ...(macosLoginSessionWatch ? ['--login-session-watch'] : []), ...daemonLogArgs() ], @@ -557,6 +698,7 @@ function createOutOfProcessLauncher( } // Wait for the daemon to signal readiness via IPC + let launchedIdentity: DaemonEndpointIdentity | null = null await new Promise((resolve, reject) => { let timer: ReturnType | undefined let settled = false @@ -594,6 +736,9 @@ function createOutOfProcessLauncher( ) return } + if (Number.isSafeInteger(child.pid) && (child.pid as number) > 0) { + unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) + } reject(startupError) } function onReadyMessage(msg: unknown): void { @@ -606,22 +751,10 @@ function createOutOfProcessLauncher( void fail(new Error('Daemon readiness identity is incomplete')) return } - try { - // Why: pid record shares the daemon's self time and nonce so cleanup can identify this exact process incarnation. - writeFileSync( - pidPath, - serializeDaemonPidFile({ - pid: child.pid as number, - ...readyIdentity, - entryPath, - appVersion: app.getVersion(), - launchNonce - }), - { mode: 0o600, flag: 'wx' } - ) - } catch (error) { - void fail(error instanceof Error ? error : new Error(String(error))) - return + launchedIdentity = { + pid: child.pid as number, + ...readyIdentity, + launchNonce } settled = true // Why: daemon is detached after readiness; detach startup listeners so the launch promise closure isn't retained. @@ -652,14 +785,25 @@ function createOutOfProcessLauncher( }) try { + if (!launchedIdentity) { + throw new Error('Daemon readiness identity is incomplete') + } return await holdDaemonAdoptionLease( { shutdown: () => terminateLaunchedDaemonChild(child) }, socketPath, - tokenPath + tokenPath, + undefined, + launchedIdentity, + pidPath ) } catch (error) { + if (error instanceof DaemonEndpointOwnershipError) { + await terminateLaunchedDaemonChild(child) + unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) + throw error + } // Why: another client may have adopted this live process; keep its pid record until exit, but remove one published after an early exit. let pidRecordRemoved = false const removeExitedPidRecord = (): void => { @@ -698,6 +842,11 @@ export async function initDaemonPtyProvider( } const runtimeDir = getRuntimeDir() + // Why: rename-claim and bind scratch names are unlinked by their owner, but a failed unlink + // leaves one behind forever. Sweep before launching so a failed launch is still reclaimed; + // age-gated so a claim still in flight is never disturbed. + sweepAbandonedDaemonClaims(runtimeDir) + const newSpawner = new DaemonSpawner({ runtimeDir, launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false) @@ -797,7 +946,9 @@ export async function initDaemonPtyProvider( setLocalPtyProvider(routedAdapter) // Why: the first window may register PTY listeners before daemon init finishes; rebind so daemon PTYs still fan out events. rebindLocalProviderListeners() - logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length }) + logDaemonMilestone('daemon-init-done', { + legacyAdapters: legacyAdapters.length + }) await reconcileSeededClaudeLivePtys(routedAdapter) } @@ -1084,7 +1235,8 @@ export async function cleanupDaemonForProtocol( didRequestShutdown = true } catch { // Previous-protocol daemons may be wedged or too old for the RPC path; fall back to PID cleanup (only unlinks a live socket after proving the process is killed). - didKillStaleDaemon = await killStaleDaemon(runtimeDir, socketPath, tokenPath, protocolVersion) + didKillStaleDaemon = (await killStaleDaemon(runtimeDir, socketPath, tokenPath, protocolVersion)) + .killed } finally { client.disconnect() } diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 3976c6c21e6..244a05f117b 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -7,6 +7,9 @@ export type DaemonStartOptions = { pidPath?: string launchNonce?: string startedAtMs?: number + publishEndpointOwnership?: DaemonServerOptions['publishEndpointOwnership'] + entryPath?: string + appVersion?: string /** Direct-construction seam for versioned protocol fixtures; never CLI/env configured. */ protocolVersion?: number spawnSubprocess: DaemonServerOptions['spawnSubprocess'] @@ -15,6 +18,7 @@ export type DaemonStartOptions = { onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair'] log?: DaemonFileLog onIdleShutdown?: () => void + onRpcShutdown?: () => void initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig'] } @@ -29,6 +33,11 @@ export async function startDaemon(opts: DaemonStartOptions): Promise { + if (process.platform !== 'linux') { + return null + } + try { + const linuxStartTicks = parseLinuxStartTicks(readFileSync(`/proc/${pid}/stat`, 'utf8')) + const bootId = await readBootIdentity() + return linuxStartTicks && bootId ? { linuxStartTicks, bootId } : null + } catch { + return null + } +} + export function parseDaemonReadyIdentity(message: unknown): DaemonReadyIdentity | null { if (!message || typeof message !== 'object') { return null diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index b6588e44e69..053c5051033 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -87,11 +87,12 @@ describe('DaemonServer', () => { rmSync(dir, { recursive: true, force: true }) }) - async function startServer(launchNonce?: string): Promise { + async function startServer(launchNonce?: string, onRpcShutdown?: () => void): Promise { server = new DaemonServer({ socketPath, tokenPath, ...(launchNonce ? { pidPath, launchNonce } : {}), + ...(onRpcShutdown ? { onRpcShutdown } : {}), spawnSubprocess: () => createMockSubprocess() }) await server.start() @@ -842,7 +843,8 @@ describe('DaemonServer', () => { describe('shutdown', () => { it('waits for the ordinary shutdown reply write before destroying resources', async () => { - await startServer() + const onRpcShutdown = vi.fn() + await startServer(undefined, onRpcShutdown) const c = await connectClient() const daemon = server as unknown as DaemonServerPrivate & { host: { dispose: () => Promise } @@ -861,11 +863,14 @@ describe('DaemonServer', () => { await expect(c.request('shutdown', { killSessions: false })).resolves.toEqual({}) expect(dispose).not.toHaveBeenCalled() + expect(onRpcShutdown).not.toHaveBeenCalled() expect(existsSync(tokenPath)).toBe(true) replyFlushed?.() - await waitFor(() => !existsSync(tokenPath)) + await waitFor(() => onRpcShutdown.mock.calls.length === 1) + expect(existsSync(tokenPath)).toBe(false) expect(dispose).toHaveBeenCalledOnce() + expect(onRpcShutdown).toHaveBeenCalledOnce() }) it('removes only its owned token and PID record', async () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 4c9ffd5098e..6b13acc779f 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -2,7 +2,7 @@ import { createServer, type Server, type Socket } from 'node:net' import { randomUUID } from 'node:crypto' import { performance } from 'node:perf_hooks' -import { writeFileSync, chmodSync } from 'node:fs' +import { writeFileSync, chmodSync, unlinkSync } from 'node:fs' import { StringDecoder } from 'node:string_decoder' import { encodeNdjson, createNdjsonParser } from './ndjson' import { TerminalHost } from './terminal-host' @@ -23,6 +23,13 @@ import { createNoopDaemonFileLog, type DaemonFileLog } from './daemon-file-log' import { isTuiAgent } from '../../shared/tui-agent-config' import { parsePtyStartupIngressIntent } from '../../shared/pty-startup-ingress' import { unlinkOwnedDaemonPidFile, unlinkOwnedDaemonTokenFile } from './daemon-spawner' +import { + getDaemonSocketBindPath, + publishDaemonSocketPath, + readDaemonEndpointOwnershipState, + unlinkOwnedDaemonSocketPath, + type DaemonSocketIdentity +} from './daemon-endpoint-ownership' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, PROTOCOL_VERSION, @@ -44,9 +51,14 @@ export type DaemonServerOptions = { pidPath?: string launchNonce?: string startedAtMs?: number + publishEndpointOwnership?: () => void + /** Reported in the hello so a repaired PID record can carry the real owner's metadata. */ + entryPath?: string + appVersion?: string /** Direct-construction seam for protocol fixture tests; production never overrides it. */ protocolVersion?: number onIdleShutdown?: () => void + onRpcShutdown?: () => void /** Direct-construction-only controls; production uses the compiled initial-adoption timeout. */ initialAdoptionTestConfig?: { timeoutMs: number @@ -95,6 +107,8 @@ export class DaemonServer { // Why: survive long enough to adopt a first client pair, but don't orphan forever if the parent crashes first. private static readonly INITIAL_ADOPTION_TIMEOUT_MS = 2 * 60 * 1000 private static readonly SHUTDOWN_REPLY_FLUSH_TIMEOUT_MS = 1_000 + private static readonly ENDPOINT_OWNERSHIP_POLL_MS = 30 * 1000 + private static readonly ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS = 2 private server: Server | null = null private token: string private host: TerminalHost @@ -103,8 +117,15 @@ export class DaemonServer { private pidPath: string | null private launchNonce: string | null private startedAtMs: number | null + private publishEndpointOwnership: () => void + private entryPath: string | null + private appVersion: string | null + private ownedSocketIdentity: DaemonSocketIdentity | null = null + private endpointOwnershipTimer: ReturnType | null = null + private endpointOwnershipLossStreak = 0 private protocolVersion: number private onIdleShutdown: () => void + private onRpcShutdown: () => void private onAuthenticatedClientPair: () => void private ptySpawnHealthCheck: () => Promise private preparePtySpawn: () => Promise @@ -173,7 +194,11 @@ export class DaemonServer { (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION ? Date.now() - process.uptime() * 1000 : null) + this.publishEndpointOwnership = opts.publishEndpointOwnership ?? (() => {}) + this.entryPath = opts.entryPath ?? null + this.appVersion = opts.appVersion ?? null this.onIdleShutdown = opts.onIdleShutdown ?? (() => {}) + this.onRpcShutdown = opts.onRpcShutdown ?? (() => {}) this.initialAdoptionTimeoutMs = opts.initialAdoptionTestConfig?.timeoutMs ?? DaemonServer.INITIAL_ADOPTION_TIMEOUT_MS this.lifecycleClock = opts.initialAdoptionTestConfig?.clock ?? { @@ -213,19 +238,58 @@ export class DaemonServer { this.server.once('error', onListenError) - this.server.listen(this.socketPath, () => { + // Why: bind a private name and hard-link it into place, so libuv's close-time unlink + // can only ever remove our own bind name — never a replacement daemon's endpoint. + const bindPath = + process.platform === 'win32' ? this.socketPath : getDaemonSocketBindPath(this.socketPath) + + this.server.listen(bindPath, () => { // Why: drop the startup error listener after bind so it doesn't retain this closure. this.server?.off('error', onListenError) - writeFileSync(this.tokenPath, this.token, { mode: 0o600 }) try { - chmodSync(this.socketPath, 0o600) + // Why: tighten the mode on the private bind name so the endpoint is never reachable + // at the canonical path with default permissions, even briefly. + chmodSync(bindPath, 0o600) } catch { // Best-effort on platforms that support it } + let publishedOwnership = false + try { + // Why: the exclusive link is the endpoint claim, and the PID/nonce record must + // exist before the token makes this listener adoptable. + this.ownedSocketIdentity = publishDaemonSocketPath(bindPath, this.socketPath) + this.publishEndpointOwnership() + publishedOwnership = true + writeFileSync(this.tokenPath, this.token, { mode: 0o600 }) + } catch (error) { + // Why: roll back only a record we actually wrote. Losing the endpoint claim means + // the record at that path belongs to the incumbent daemon, and even the ownership- + // checked unlink briefly renames it aside — enough to strand a live daemon's record. + if (publishedOwnership && this.pidPath && this.launchNonce) { + unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) + } + unlinkOwnedDaemonSocketPath(this.socketPath, this.ownedSocketIdentity) + this.ownedSocketIdentity = null + const server = this.server + this.server = null + // Why: settle before close — an already-accepted connection defers the close + // callback indefinitely, and start() has no timeout of its own. + reject(error) + server?.close() + if (process.platform !== 'win32') { + try { + unlinkSync(bindPath) + } catch { + // Already consumed by a successful link, or never created. + } + } + return + } if (this.protocolVersion >= CLEAN_DISCONNECT_PROTOCOL_VERSION) { // Why: a parent crash before the first full client pair must not leave an empty daemon alive forever. this.armInitialAdoptionTimeout() } + this.startEndpointOwnershipWatch() resolve() }) }) @@ -253,15 +317,87 @@ export class DaemonServer { await serverClose } + private async finishRpcShutdown(serverClose: Promise): Promise { + await this.finishOrdinaryShutdown(serverClose) + this.onRpcShutdown() + } + private unlinkOwnedEndpointArtifacts(): void { - // Why: ownership checks prevent removing a late replacement's token or PID record. + // Why: ownership checks prevent removing a late replacement's token, PID record or endpoint. unlinkOwnedDaemonTokenFile(this.tokenPath, this.token) if (this.pidPath && this.launchNonce) { unlinkOwnedDaemonPidFile(this.pidPath, process.pid, this.launchNonce) } + // Why: we bound a private name, so libuv unlinks nothing at the canonical path — this + // is the only removal of our endpoint, and it is skipped once someone else owns it. + unlinkOwnedDaemonSocketPath(this.socketPath, this.ownedSocketIdentity) + this.ownedSocketIdentity = null + } + + private startEndpointOwnershipWatch(): void { + if (process.platform === 'win32' || !this.ownedSocketIdentity) { + return + } + this.endpointOwnershipTimer = setInterval( + () => this.checkEndpointOwnership(), + DaemonServer.ENDPOINT_OWNERSHIP_POLL_MS + ) + // Why: a liveness poll must never be the reason the process cannot exit. + this.endpointOwnershipTimer.unref() + } + + private stopEndpointOwnershipWatch(): void { + if (this.endpointOwnershipTimer === null) { + return + } + clearInterval(this.endpointOwnershipTimer) + this.endpointOwnershipTimer = null + } + + /** + * Retires a daemon that no longer owns the canonical endpoint. + * + * Why: a daemon whose endpoint name was taken over keeps hosting PTYs that no client can + * reach through the socket, which reads to the user as terminals that acknowledge input and + * never run it. Retirement drains rather than kills: live sessions finish, and the process + * exits once idle instead of surviving as an unreachable orphan. + */ + private checkEndpointOwnership(): void { + if (process.platform === 'win32' || !this.ownedSocketIdentity || this.shutdownPromise) { + return + } + const state = readDaemonEndpointOwnershipState(this.socketPath, this.ownedSocketIdentity) + if (state === 'owned') { + this.endpointOwnershipLossStreak = 0 + return + } + if (state === 'indeterminate') { + // Why: an inconclusive stat proves nothing. Retiring on EACCES or EIO would take down a + // daemon that is still serving every terminal on the machine. Reset the streak too, so + // the confirmations we act on are consecutive rather than merely cumulative. + this.endpointOwnershipLossStreak = 0 + return + } + this.endpointOwnershipLossStreak++ + // Why: a replacement publishes by unlink-then-link, so a single observation can land in + // that gap. Require the loss to persist before acting on it. + if (this.endpointOwnershipLossStreak < DaemonServer.ENDPOINT_OWNERSHIP_LOSS_CONFIRMATIONS) { + return + } + if (this.retirementRequested) { + return + } + this.log.log('endpoint-ownership-lost', { socketPath: this.socketPath }) + console.warn( + '[daemon] Endpoint ownership lost to another daemon — retiring once existing sessions end' + ) + this.ownedSocketIdentity = null + this.retirementRequested = true + this.reevaluateIdleShutdown() } private async disposeDaemonResources(): Promise { + this.stopEndpointOwnershipWatch() this.stopStreamBacklogProbe() this.transientFactRelay.dispose() this.cancelAllPendingPtySpawnPreparations() @@ -297,7 +433,9 @@ export class DaemonServer { return new Promise((resolve) => { // Why: close synchronously before any awaited cleanup so no new transport enters after the empty proof. server.close(() => { - // Node owns unlinking its Unix listener; an extra unlink here could delete a concurrent replacement. + // Why: libuv unlinks the path this server bound, which is our private bind name and + // is already gone. The canonical endpoint is removed by unlinkOwnedEndpointArtifacts, + // under an ownership check, so closing late cannot delete a replacement's endpoint. resolve() }) }) @@ -436,19 +574,31 @@ export class DaemonServer { reason: 'protocol-mismatch', clientVersion: hello.version }) - socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Protocol version mismatch' })) + socket.write( + encodeNdjson({ + type: 'hello', + ok: false, + error: 'Protocol version mismatch' + }) + ) socket.destroy() return } if (hello.token !== this.token) { - this.log.log('client-hello-rejected', { reason: 'invalid-token', role: hello.role }) + this.log.log('client-hello-rejected', { + reason: 'invalid-token', + role: hello.role + }) socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Invalid token' })) socket.destroy() return } - this.log.log('client-hello-accepted', { role: hello.role, clientId: hello.clientId }) + this.log.log('client-hello-accepted', { + role: hello.role, + clientId: hello.clientId + }) socket.write( encodeNdjson({ type: 'hello', @@ -458,7 +608,9 @@ export class DaemonServer { daemonIdentity: { pid: process.pid, startedAtMs: this.startedAtMs, - launchNonce: this.launchNonce + launchNonce: this.launchNonce, + ...(this.entryPath ? { entryPath: this.entryPath } : {}), + ...(this.appVersion ? { appVersion: this.appVersion } : {}) } } : {}) @@ -639,7 +791,10 @@ export class DaemonServer { } private async preparePtySpawnUnlessCanceled(sessionId: string, clientId: string): Promise { - const preparation: PendingPtySpawnPreparation = { canceled: false, clientId } + const preparation: PendingPtySpawnPreparation = { + canceled: false, + clientId + } const pending = this.pendingPtySpawnPreparations.get(sessionId) ?? new Set() pending.add(preparation) this.pendingPtySpawnPreparations.set(sessionId, pending) @@ -790,7 +945,10 @@ export class DaemonServer { }, onExit: (code, incarnationId) => { // Why: exit tears down renderer handlers, so it must ride the ordered queue behind final output. - this.log.log('session-exited', { sessionId: routedSessionId, code }) + this.log.log('session-exited', { + sessionId: routedSessionId, + code + }) this.streamDataBatcher.enqueueControlEvent(clientId, routedSessionId, { type: 'event', event: 'exit', @@ -942,7 +1100,9 @@ export class DaemonServer { clientId } try { - await this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate }) + await this.host.kill(request.payload.sessionId, { + immediate: request.payload.immediate + }) } catch (error) { // Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done. if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) { @@ -960,14 +1120,18 @@ export class DaemonServer { case 'detach': // Note: detach token handling simplified — full impl would track tokens per client - this.log.log('session-detached', { sessionId: request.payload.sessionId }) + this.log.log('session-detached', { + sessionId: request.payload.sessionId + }) return {} case 'getCwd': return { cwd: await this.host.getCwd(request.payload.sessionId) } case 'getForegroundProcess': - return { foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) } + return { + foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) + } case 'inspectProcess': return this.host.inspectProcess(request.payload.sessionId) @@ -1019,7 +1183,9 @@ export class DaemonServer { typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows) ? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows))) : undefined - const snapshot = this.host.getSnapshot(request.payload.sessionId, { scrollbackRows }) + const snapshot = this.host.getSnapshot(request.payload.sessionId, { + scrollbackRows + }) const snapshotMs = performance.now() - snapshotStart if (snapshotMs >= 25) { // Serialize stalls block the daemon's single thread; surface them to attribute field typing stalls (issue #5096 family). @@ -1071,10 +1237,10 @@ export class DaemonServer { const controlSocket = this.clients.get(clientId)?.controlSocket if (controlSocket) { this.deferShutdownUntilReply(clientId, request.id, controlSocket, () => - this.finishOrdinaryShutdown(serverClose) + this.finishRpcShutdown(serverClose) ) } else if (!this.shutdownPromise) { - this.shutdownPromise = this.finishOrdinaryShutdown(serverClose) + this.shutdownPromise = this.finishRpcShutdown(serverClose) } return {} } diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index 613b9e6a149..0c5b987851e 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -1,14 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, connect, type Server } from 'node:net' import { DaemonSpawner, getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath, + publishDaemonPidFile, + replaceDaemonPidFile, restoreClaimedDaemonArtifact } from './daemon-spawner' +import { + getDaemonSocketBindPath, + publishDaemonSocketPath, + sweepAbandonedDaemonClaims, + unlinkOwnedDaemonSocketPath +} from './daemon-endpoint-ownership' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' import type { SubprocessHandle } from './session' @@ -235,3 +245,214 @@ describe('restoreClaimedDaemonArtifact', () => { ).toBe(true) }) }) + +describe('daemon PID publication', () => { + it('publishes ownership exclusively', () => { + const dir = createTestDir() + const pidPath = join(dir, 'daemon.pid') + try { + publishDaemonPidFile(pidPath, { + pid: 101, + startedAtMs: 1_000, + launchNonce: 'launch-a' + }) + + expect(() => + publishDaemonPidFile(pidPath, { + pid: 202, + startedAtMs: 2_000, + launchNonce: 'launch-b' + }) + ).toThrow() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('atomically replaces stale ownership with the authenticated endpoint identity', () => { + const dir = createTestDir() + const pidPath = join(dir, 'daemon.pid') + const endpointIdentity = { + pid: 202, + startedAtMs: 2_000, + launchNonce: 'launch-b' + } + try { + writeFileSync(pidPath, '{"pid":101,"launchNonce":"launch-a"}') + + expect(replaceDaemonPidFile(pidPath, endpointIdentity)).toBe(true) + expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual(endpointIdentity) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('reports failure and preserves the record when the rename claim fails for any reason but absence', () => { + // A read-only parent denies the rename with EACCES, standing in for the Windows + // AV/indexer lock that fails the claim on a record which is merely open. + if (process.platform === 'win32' || process.getuid?.() === 0) { + return + } + const dir = createTestDir() + const pidPath = join(dir, 'daemon.pid') + const existingRecord = '{"pid":101,"launchNonce":"launch-a"}' + writeFileSync(pidPath, existingRecord) + try { + chmodSync(dir, 0o500) + + expect( + replaceDaemonPidFile(pidPath, { pid: 202, startedAtMs: 2_000, launchNonce: 'launch-b' }) + ).toBe(false) + expect(readFileSync(pidPath, 'utf8')).toBe(existingRecord) + } finally { + chmodSync(dir, 0o700) + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +function listenOnSocketPath(server: Server, socketPath: string): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(socketPath, () => { + server.off('error', reject) + resolve() + }) + }) +} + +function closeSocketServer(server: Server): Promise { + return new Promise((resolve) => { + if (!server.listening) { + resolve() + return + } + server.close(() => resolve()) + }) +} + +function connectsToSocketPath(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = connect({ path: socketPath }) + const timer = setTimeout(() => { + socket.destroy() + resolve(false) + }, 500) + socket.on('connect', () => { + clearTimeout(timer) + socket.destroy() + resolve(true) + }) + socket.on('error', () => { + clearTimeout(timer) + resolve(false) + }) + }) +} + +describe('daemon socket publication', () => { + it('keeps the bind name shorter than the canonical endpoint', () => { + // sockaddr_un caps the path, so the private bind name must never extend it. + const canonicalPath = getDaemonSocketPath('/tmp/orca-daemon-runtime') + + expect(getDaemonSocketBindPath(canonicalPath).length).toBeLessThan(canonicalPath.length) + }) + + it('publishes a bound listener under the canonical endpoint and reclaims only its own entry', async () => { + if (process.platform === 'win32') { + return + } + const dir = createTestDir() + const canonicalPath = getDaemonSocketPath(dir) + const first = createServer((socket) => socket.end()) + const second = createServer((socket) => socket.end()) + try { + const firstBindPath = getDaemonSocketBindPath(canonicalPath) + await listenOnSocketPath(first, firstBindPath) + + const firstIdentity = publishDaemonSocketPath(firstBindPath, canonicalPath) + expect(firstIdentity).not.toBeNull() + expect(existsSync(canonicalPath)).toBe(true) + expect(existsSync(firstBindPath)).toBe(false) + await expect(connectsToSocketPath(canonicalPath)).resolves.toBe(true) + + const secondBindPath = getDaemonSocketBindPath(canonicalPath) + await listenOnSocketPath(second, secondBindPath) + let publishError: NodeJS.ErrnoException | null = null + try { + publishDaemonSocketPath(secondBindPath, canonicalPath) + } catch (error) { + publishError = error as NodeJS.ErrnoException + } + expect(publishError?.code).toBe('EEXIST') + + expect(unlinkOwnedDaemonSocketPath(canonicalPath, firstIdentity)).toBe(true) + expect(existsSync(canonicalPath)).toBe(false) + + // The endpoint name now resolves to a different listener's inode. + const secondIdentity = publishDaemonSocketPath(secondBindPath, canonicalPath) + expect(secondIdentity).not.toEqual(firstIdentity) + expect(unlinkOwnedDaemonSocketPath(canonicalPath, firstIdentity)).toBe(false) + expect(existsSync(canonicalPath)).toBe(true) + await expect(connectsToSocketPath(canonicalPath)).resolves.toBe(true) + } finally { + await closeSocketServer(first) + await closeSocketServer(second) + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('sweepAbandonedDaemonClaims', () => { + const claimNames = [ + `daemon-v${PROTOCOL_VERSION}.pid.cleanup-123-${randomUUID()}`, + `daemon-v${PROTOCOL_VERSION}.pid.replace-123-${randomUUID()}`, + '.b0123456789' + ] + const preservedNames = [`daemon-v${PROTOCOL_VERSION}.pid`, `daemon-v${PROTOCOL_VERSION}.token`] + + function seedClaimDir(): string { + const dir = createTestDir() + for (const name of [...claimNames, ...preservedNames]) { + writeFileSync(join(dir, name), 'x') + } + return dir + } + + it('removes aged claim and bind scratch names without touching daemon artifacts', () => { + const dir = seedClaimDir() + try { + expect(sweepAbandonedDaemonClaims(dir, undefined, Date.now() + 24 * 60 * 60 * 1000)).toBe( + claimNames.length + ) + + for (const name of claimNames) { + expect(existsSync(join(dir, name))).toBe(false) + } + for (const name of preservedNames) { + expect(existsSync(join(dir, name))).toBe(true) + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('leaves freshly written claims alone so an in-flight claim is never stolen', () => { + const dir = seedClaimDir() + try { + expect(sweepAbandonedDaemonClaims(dir)).toBe(0) + + for (const name of [...claimNames, ...preservedNames]) { + expect(existsSync(join(dir, name))).toBe(true) + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('returns zero for an unreadable runtime dir', () => { + expect(sweepAbandonedDaemonClaims(join(tmpdir(), `daemon-sweep-missing-${randomUUID()}`))).toBe( + 0 + ) + }) +}) diff --git a/src/main/daemon/daemon-spawner.ts b/src/main/daemon/daemon-spawner.ts index 68b2a5afce4..2618cdf27aa 100644 --- a/src/main/daemon/daemon-spawner.ts +++ b/src/main/daemon/daemon-spawner.ts @@ -1,5 +1,13 @@ import { createHash, randomUUID } from 'node:crypto' -import { constants, copyFileSync, existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs' +import { + constants, + copyFileSync, + existsSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync +} from 'node:fs' import { join } from 'node:path' import { PROTOCOL_VERSION } from './types' @@ -111,6 +119,56 @@ export function serializeDaemonPidFile(pidFile: DaemonPidFile): string { return JSON.stringify(pidFile) } +function isMissingFileError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' +} + +export function publishDaemonPidFile(pidPath: string, pidFile: DaemonPidFile): void { + writeFileSync(pidPath, serializeDaemonPidFile(pidFile), { + mode: 0o600, + flag: 'wx' + }) +} + +export function replaceDaemonPidFile(pidPath: string, pidFile: DaemonPidFile): boolean { + const claimedPath = `${pidPath}.replace-${process.pid}-${randomUUID()}` + let claimedExisting = false + try { + renameSync(pidPath, claimedPath) + claimedExisting = true + } catch (error) { + // Why: only a genuinely absent record is safe to treat as unclaimed. On Windows an + // external opener without FILE_SHARE_DELETE (AV, indexer, backup) fails the rename + // with EPERM/EACCES/EBUSY; falling through would then hit EEXIST on the exclusive + // publish and report a false ownership conflict for a record that is simply locked. + if (!isMissingFileError(error)) { + return false + } + } + + try { + publishDaemonPidFile(pidPath, pidFile) + } catch { + if (claimedExisting && restoreClaimedDaemonArtifact(claimedPath, pidPath)) { + try { + unlinkSync(claimedPath) + } catch { + // A uniquely named restored claim is inert. + } + } + return false + } + + if (claimedExisting) { + try { + unlinkSync(claimedPath) + } catch { + // The canonical record is authoritative; the uniquely named claim is inert. + } + } + return true +} + export function unlinkOwnedDaemonPidFile( pidPath: string, expectedPid: number, @@ -118,7 +176,10 @@ export function unlinkOwnedDaemonPidFile( ): boolean { return claimAndUnlinkOwnedFile(pidPath, (content) => { try { - const parsed = JSON.parse(content) as { pid?: unknown; launchNonce?: unknown } + const parsed = JSON.parse(content) as { + pid?: unknown + launchNonce?: unknown + } return parsed.pid === expectedPid && parsed.launchNonce === expectedLaunchNonce } catch { return false diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts index 490f15124d7..4404c5c2c99 100644 --- a/src/main/daemon/production-launcher.test.ts +++ b/src/main/daemon/production-launcher.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createProductionLauncher } from './production-launcher' import { startDaemon, type DaemonHandle } from './daemon-main' import { DaemonClient } from './client' @@ -148,18 +148,18 @@ describe('createProductionLauncher', () => { expect(handlers.exit).toHaveLength(0) expect(child.disconnect).toHaveBeenCalled() expect(child.unref).toHaveBeenCalled() - expect(JSON.parse(readFileSync(pidPath, 'utf8'))).toEqual({ - pid: 12345, - startedAtMs: 123_456, - linuxStartTicks: '4242', - bootId: 'boot-a', - entryPath: join(dir, 'daemon-entry.js'), - appVersion: '1.2.3', - launchNonce: 'launch-a' - }) expect(forkMock).toHaveBeenCalledWith( join(dir, 'daemon-entry.js'), - expect.arrayContaining(['--pid-record', pidPath, '--launch-nonce', 'launch-a']), + expect.arrayContaining([ + '--pid-record', + pidPath, + '--launch-nonce', + 'launch-a', + '--entry-path', + join(dir, 'daemon-entry.js'), + '--app-version', + '1.2.3' + ]), expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }) ) }) @@ -208,7 +208,9 @@ describe('createProductionLauncher', () => { getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), getAppVersion: () => '1.2.3' }) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) + const pidPath = join(dir, 'daemon.pid') + writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-shutdown' })) + const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-shutdown') handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) const handle = await launch @@ -216,6 +218,7 @@ describe('createProductionLauncher', () => { expect(child.kill).toHaveBeenCalledWith('SIGTERM') expect(child.exitCode).toBe(0) + expect(existsSync(pidPath)).toBe(false) }) it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => { @@ -337,7 +340,7 @@ describe('createProductionLauncher', () => { } }) - it('preserves PID publication and cleanup failures', async () => { + it('preserves exact PID ownership when child exit cannot be confirmed', async () => { const handlers: Record void)[]> = { message: [], error: [], @@ -370,24 +373,65 @@ describe('createProductionLauncher', () => { unref: vi.fn() } forkMock.mockReturnValueOnce(child) - const pidPath = join(dir, 'occupied.pid') - writeFileSync(pidPath, 'occupied') + const launcher = createProductionLauncher({ + getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), + getAppVersion: () => '1.2.3' + }) + const pidPath = join(dir, 'daemon.pid') + writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-b' })) + + const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') + handlers.message[0]?.({ type: 'ready' }) + + const error = await launch.catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([ + expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), + signalError + ]) + expect(child.disconnect).toHaveBeenCalledOnce() + expect(child.unref).toHaveBeenCalledOnce() + expect(existsSync(pidPath)).toBe(true) + }) + + it('removes exact child-side PID ownership after a pre-ready exit', async () => { + const handlers: Record void)[]> = { + message: [], + error: [], + exit: [] + } + const child = { + pid: 12345, + connected: true, + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + on: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event]?.push(cb) + return child + }), + off: vi.fn((event: string, cb: (arg?: unknown) => void) => { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return child + }), + disconnect: vi.fn(() => { + child.connected = false + }), + unref: vi.fn() + } + forkMock.mockReturnValueOnce(child) + const pidPath = join(dir, 'daemon.pid') + writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-c' })) const launcher = createProductionLauncher({ getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), getAppVersion: () => '1.2.3' }) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) + const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-c') + child.exitCode = 1 + handlers.exit[0]?.(1) - const error = await launch.catch((caught: unknown) => caught) - expect(error).toBeInstanceOf(AggregateError) - expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ code: 'EEXIST' }), - signalError - ]) - expect(child.disconnect).toHaveBeenCalledOnce() - expect(child.unref).toHaveBeenCalledOnce() + await expect(launch).rejects.toThrow('Daemon process exited prematurely with code 1') + expect(existsSync(pidPath)).toBe(false) }) }) diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts index 79bf439eead..342ff6e94ba 100644 --- a/src/main/daemon/production-launcher.ts +++ b/src/main/daemon/production-launcher.ts @@ -1,7 +1,6 @@ import { fork, type ChildProcess } from 'node:child_process' -import { writeFileSync } from 'node:fs' import { - serializeDaemonPidFile, + unlinkOwnedDaemonPidFile, type DaemonLauncher, type DaemonProcessHandle } from './daemon-spawner' @@ -28,6 +27,7 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo } const entryPath = opts.getDaemonEntryPath() + const appVersion = opts.getAppVersion() const child = fork( entryPath, [ @@ -35,7 +35,18 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo socketPath, '--token', tokenPath, - ...(pidPath && launchNonce ? ['--pid-record', pidPath, '--launch-nonce', launchNonce] : []) + ...(pidPath && launchNonce + ? [ + '--pid-record', + pidPath, + '--launch-nonce', + launchNonce, + '--entry-path', + entryPath, + '--app-version', + appVersion + ] + : []) ], { // Why: detached daemon output is not consumed; ignored streams cannot @@ -47,31 +58,18 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo } ) - let readyIdentity: DaemonReadyIdentity try { - readyIdentity = await waitForReady(child) + await waitForReady(child) } catch (error) { - return rejectAfterChildCleanup(child, error) + return rejectAfterChildCleanup(child, error, pidPath, launchNonce) } - if (pidPath && launchNonce) { - if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) { - return rejectAfterChildCleanup(child, new Error('Daemon readiness identity is incomplete')) - } - try { - writeFileSync( - pidPath, - serializeDaemonPidFile({ - pid: child.pid as number, - ...readyIdentity, - entryPath, - appVersion: opts.getAppVersion(), - launchNonce - }), - { mode: 0o600, flag: 'wx' } - ) - } catch (error) { - return rejectAfterChildCleanup(child, error) - } + if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) { + return rejectAfterChildCleanup( + child, + new Error('Daemon readiness identity is incomplete'), + pidPath, + launchNonce + ) } // Unref so the Electron process can exit without waiting for the daemon @@ -79,7 +77,10 @@ export function createProductionLauncher(opts: ProductionLauncherOptions): Daemo child.disconnect() return { - shutdown: () => shutdownChild(child) + shutdown: async () => { + await shutdownChild(child) + unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) + } } } } @@ -208,7 +209,12 @@ async function shutdownChild(child: ChildProcess): Promise { } } -async function rejectAfterChildCleanup(child: ChildProcess, launchError: unknown): Promise { +async function rejectAfterChildCleanup( + child: ChildProcess, + launchError: unknown, + pidPath?: string, + launchNonce?: string +): Promise { try { await shutdownChild(child) } catch (cleanupError) { @@ -217,9 +223,20 @@ async function rejectAfterChildCleanup(child: ChildProcess, launchError: unknown 'Daemon launch and child cleanup both failed' ) } + unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) throw launchError } +function unlinkLaunchedDaemonPidFile( + child: ChildProcess, + pidPath?: string, + launchNonce?: string +): void { + if (pidPath && launchNonce && Number.isSafeInteger(child.pid) && (child.pid as number) > 0) { + unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) + } +} + function isNoSuchProcessError(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' } From b0ba51831c1cd8c29c73482c6caaed15beced75f Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:17:45 -0700 Subject: [PATCH 28/46] Add per-worker model and effort overrides (#12851) --- .../orchestration-skill-guidance.test.mjs | 10 ++ skill-guides/orchestration.md | 8 + src/cli/bundled-skill-guides.ts | 2 +- .../handlers/orchestration-worker-cli.test.ts | 58 +++++++ src/cli/handlers/orchestration.ts | 20 ++- src/cli/help.ts | 2 + src/cli/specs/orchestration-worker-specs.ts | 5 +- src/main/runtime/orca-runtime.ts | 18 +- .../orchestration-federated-worker-start.ts | 75 ++++---- ...hestration-federation-agent-launch.test.ts | 24 ++- .../orchestration-federation-start-receipt.ts | 3 + .../orchestration-federation-start-schema.ts | 5 + .../orchestration-federation-test-request.ts | 25 +++ .../methods/orchestration-federation.test.ts | 41 +++-- .../rpc/methods/orchestration-federation.ts | 50 ++---- ...stration-worker-launch-preferences.test.ts | 135 ++++++++++++++ ...orchestration-worker-launch-preferences.ts | 158 +++++++++++++++++ .../orchestration-worker-start-receipt.ts | 3 + .../orchestration-worker-start-schema.ts | 9 + .../orchestration-worker-start-validation.ts | 164 ++++++++++++++++++ .../methods/orchestration-worker-topology.ts | 5 + ...orchestration-workers-new-worktree.test.ts | 23 +++ .../rpc/methods/orchestration-workers.ts | 41 +---- .../runtime/rpc/methods/orchestration.test.ts | 55 ++++++ src/shared/agent-session-option-agent-args.ts | 35 ++++ ...ent-session-option-catalog-claude-codex.ts | 44 ++++- ...nt-session-option-catalog-gemini-cursor.ts | 5 +- .../agent-session-option-catalog-types.ts | 6 + src/shared/agent-session-option-launch.ts | 52 ++++-- src/shared/protocol-version.ts | 3 + src/shared/tui-agent-launch-command.ts | 59 ++++++- .../tui-agent-startup-session-options.test.ts | 54 ++++++ src/shared/tui-agent-startup.ts | 6 +- 33 files changed, 1039 insertions(+), 164 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration-federation-test-request.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts create mode 100644 src/shared/agent-session-option-agent-args.ts diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index e869f652e40..080cb4fa785 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -275,6 +275,16 @@ describe('orchestration skill guidance', () => { ) }) + it('documents per-invocation model and effort for supervised workers', () => { + const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop') + + expect(workerLoop).toContain('opaque provider model id with `--model`') + expect(workerLoop).toContain('`--effort` requires `--model`') + expect(workerLoop).toContain('neither option can combine with `--terminal`') + expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json') + expect(workerLoop).toContain('`launch.requested` and `launch.effective`') + }) + it('never authorizes release from idle, timeout, or worker-side triggers', () => { const skill = readSkill() const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop') diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index 7da8936ea1a..bc7b1cbbe3e 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -192,6 +192,14 @@ orca orchestration worker-start --task --worktree current --agent claud `current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `. +For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt: + +```bash +orca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json +``` + +`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option. + For a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal: ```bash diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index fadfd439030..1fcb8e0afd1 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index 95a0a7db313..a2777ec3fdb 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -8,6 +8,7 @@ vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) import { ORCHESTRATION_HANDLERS } from './orchestration' import { printResult } from '../format' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' describe('orchestration worker-start CLI contract', () => { beforeEach(() => { @@ -84,6 +85,63 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBeUndefined() }) + it('capability-gates and forwards per-invocation launch preferences', async () => { + callMock + .mockResolvedValueOnce({ + result: { + capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY] + } + }) + .mockResolvedValueOnce({ + result: { + runId: 'run_1', + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'ready', + effects: [], + residualResources: [] + } + }) + + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'claude'], + ['model', 'aws-bedrock-opus-5'], + ['effort', 'high'], + ['from', 'term_coord'] + ]) + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'status.get') + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'orchestration.workerStart', + expect.objectContaining({ + agent: 'claude', + model: 'aws-bedrock-opus-5', + effort: 'high' + }) + ) + }) + + it('fails before worker-start when the runtime would strip launch preferences', async () => { + callMock.mockResolvedValueOnce({ result: { capabilities: [] } }) + + await expect( + invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'codex'], + ['model', 'gpt-5.6-sol'], + ['from', 'term_coord'] + ]) + ) + ).rejects.toMatchObject({ code: 'incompatible_runtime' }) + + expect(callMock).toHaveBeenCalledTimes(1) + }) + it('sets an unsuccessful exit code for failed and unknown receipts', async () => { callMock.mockResolvedValue({ result: { diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index f9cbdb8a41a..6668b4e1906 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -20,7 +20,8 @@ import type { OrchestrationWorkerReadSource } from '../../shared/orchestration-worker-output' import type { NativeChatMessage } from '../../shared/native-chat-types' -import type { RuntimeTerminalRead } from '../../shared/runtime-types' +import type { RuntimeStatus, RuntimeTerminalRead } from '../../shared/runtime-types' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination' import { @@ -852,6 +853,21 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration worker-start': async ({ flags, client, cwd, json }) => { + const model = getOptionalStringFlag(flags, 'model') + const effort = getOptionalStringFlag(flags, 'effort') + if (model || effort) { + const status = await client.call('status.get') + if ( + !status.result.capabilities?.includes( + ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY + ) + ) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.' + ) + } + } const result = await callMutation<{ runId: string taskId: string @@ -873,6 +889,8 @@ export const ORCHESTRATION_HANDLERS: Record = { comment: getOptionalStringFlag(flags, 'comment'), setup: getOptionalStringFlag(flags, 'setup'), agent: getOptionalStringFlag(flags, 'agent'), + model, + effort, terminal: getOptionalStringFlag(flags, 'terminal'), retryOf: getOptionalStringFlag(flags, 'retry-of'), timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), diff --git a/src/cli/help.ts b/src/cli/help.ts index e866a91ef22..3415ddad832 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -552,6 +552,8 @@ export function formatFlagHelp(flag: string): string { local: '--local Target the current project instead of the global install', skill: '--skill Bundled skill to act on; repeat for several', mode: '--mode Mode such as edit, diff, or both', + model: '--model Provider model id for a new agent launch', + effort: '--effort Reasoning effort for the selected model', 'mouse-button': '--mouse-button Mouse button: left, right, or middle', modifiers: '--modifiers Modifier keys held only for this click', name: '--name Name for the new worktree or automation', diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index a8d8b2411f5..b7f6b583a73 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -5,7 +5,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'worker-start'], summary: 'Start one supervised worker on the Run home or a connected Orca server', usage: - 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', + 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--model ] [--effort ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'task', @@ -18,6 +18,8 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'comment', 'setup', 'agent', + 'model', + 'effort', 'terminal', 'retry-of', 'timeout-ms', @@ -27,6 +29,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ ], notes: [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', + '--model supports Claude, Codex, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', '--on selects only the worker server; the Run and this command remain on the current Orca server.', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1a2aed7a74b..80a04d60554 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1293,6 +1293,7 @@ type TerminalCreateOptions = { // CLI is `cursor-agent`). Callers that know the agent name it here instead of // guessing a command, and the runtime builds the configured launch. startupAgent?: TuiAgent + launchPreferences?: AgentLaunchPreferences terminalColorQueryReplies?: TerminalOscColorQueryReplyColors viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] @@ -20956,7 +20957,8 @@ export class OrcaRuntimeService { private buildStartupForAgent( repo: Repo, agent: TuiAgent, - prompt: string | undefined + prompt: string | undefined, + launchPreferences?: AgentLaunchPreferences ): { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } { if (!this.store) { throw new Error('runtime_unavailable') @@ -20974,12 +20976,15 @@ export class OrcaRuntimeService { isRemote, terminalWindowsShell: settings.terminalWindowsShell }) + const sessionOptions = this.toAgentSessionOptions(launchPreferences) const startupPlan = buildAgentStartupPlan({ agent, prompt: prompt ?? '', cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions, + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), platform: agentLaunchPlatform, shell: queuedShell, isRemote, @@ -21418,6 +21423,7 @@ export class OrcaRuntimeService { observeSetupCompletion?: boolean createdWithAgent?: TuiAgent startupAgent?: TuiAgent + startupLaunchPreferences?: AgentLaunchPreferences startupPrompt?: string pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance @@ -21450,7 +21456,12 @@ export class OrcaRuntimeService { } const agentStartup = !args.startup && args.startupAgent - ? this.buildStartupForAgent(repo, args.startupAgent, args.startupPrompt) + ? this.buildStartupForAgent( + repo, + args.startupAgent, + args.startupPrompt, + args.startupLaunchPreferences + ) : null const draftStartup = !args.startup && !agentStartup && args.startupDraft @@ -24619,12 +24630,15 @@ export class OrcaRuntimeService { return opts } + const sessionOptions = this.toAgentSessionOptions(opts.launchPreferences) const startupPlan = buildAgentStartupPlan({ agent, prompt: '', cmdOverrides: settings.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + sessionOptions, + sessionOptionsOverrideAgentArgs: Boolean(sessionOptions), platform, shell: queuedShell, isRemote, diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts index 538dadc96d0..23a6dc0928f 100644 --- a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts @@ -11,6 +11,14 @@ import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' import { OrchestrationError } from '../../orchestration/orchestration-error' import type { WorkerStartInput } from './orchestration-worker-start-schema' +import { + assertWorkerLaunchPreferencesRuntimeSupported, + assertWorkerLaunchPreferencesCreateTerminal, + createPendingWorkerLaunchReceipt, + resolveFederatedWorkerLaunchReceipt, + type OrchestrationWorkerLaunchReceipt +} from './orchestration-worker-launch-preferences' +import { validateFederatedWorkerStartPlacement } from './orchestration-worker-start-validation' export async function startFederatedWorker(args: { params: WorkerStartInput @@ -40,7 +48,13 @@ export async function startFederatedWorker(args: { ) } const createsWorktree = worktree === 'new-top-level' - validateRemoteWorkerStart(params, createsWorktree) + assertWorkerLaunchPreferencesCreateTerminal(params) + validateFederatedWorkerStartPlacement(params, createsWorktree) + const requestedLaunch = createPendingWorkerLaunchReceipt({ + agent: isTuiAgent(params.agent) ? params.agent : null, + model: params.model, + effort: params.effort + }) const server = runtime.resolveOrchestrationWorkerServer(params.on as string) const status = (await runtime.callOrchestrationWorkerServer( @@ -62,6 +76,12 @@ export async function startFederatedWorker(args: { `Connected server ${server.name} does not support orchestration federation.` ) } + assertWorkerLaunchPreferencesRuntimeSupported({ + model: params.model, + effort: params.effort, + capabilities: status.capabilities, + serverName: server.name + }) const federationProtocolVersion = status.capabilities?.includes( ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY ) @@ -81,6 +101,7 @@ export async function startFederatedWorker(args: { baseBranch: params.baseBranch ?? null, terminal: params.terminal ?? null, agent: params.agent ?? null, + launch: requestedLaunch, timeoutMs: params.timeoutMs ?? 60_000, setup: setupDecision, setupSource: createsWorktree @@ -122,6 +143,8 @@ export async function startFederatedWorker(args: { : undefined, terminal: params.terminal, agent: params.agent, + model: params.model, + effort: params.effort, timeoutMs: params.timeoutMs, devMode: params.devMode }, @@ -134,6 +157,11 @@ export async function startFederatedWorker(args: { 'The worker server returned a different Dispatch attachment.' ) } + const launch = resolveFederatedWorkerLaunchReceipt( + remote.launch, + requestedLaunch, + remote.state === 'ready' + ) if (remote.state === 'ready' && remote.worktreeId && remote.terminalHandle) { db.updateFederatedDispatchResources({ dispatchId: started.dispatch.id, @@ -160,6 +188,7 @@ export async function startFederatedWorker(args: { stage: readyWorker.stage, server: { environmentId: server.environmentId, name: server.name }, setup: remote.setup, + launch, timeoutMs: params.timeoutMs ?? 60_000, effects: remote.effects ?? [], residualResources: remote.residualResources ?? [] @@ -171,7 +200,7 @@ export async function startFederatedWorker(args: { remote.failedStage ?? 'remote_attach', remote.lastError ?? 'The worker server reported an unknown start outcome.' ) - return federatedUnknownReceipt(worker, task.id, server.name) + return federatedUnknownReceipt(worker, task.id, server.name, launch) } const worker = db.failWorkerStart( started.dispatch.id, @@ -188,6 +217,7 @@ export async function startFederatedWorker(args: { failedStage: worker.stage, lastError: worker.last_error, setup: remote.setup, + launch, effects: remote.effects ?? [], residualResources: remote.residualResources ?? [] } @@ -204,12 +234,13 @@ export async function startFederatedWorker(args: { server: { environmentId: server.environmentId, name: server.name }, failedStage: worker.stage, lastError: worker.last_error, + launch: requestedLaunch, effects: [], residualResources: [] } } const worker = db.markWorkerStartUnknown(started.dispatch.id, 'remote_attach', reason) - return federatedUnknownReceipt(worker, task.id, server.name) + return federatedUnknownReceipt(worker, task.id, server.name, requestedLaunch) } } @@ -220,45 +251,13 @@ type RemoteStartReceipt = { worktreeId?: string terminalHandle?: string setup?: { state: string } + launch?: OrchestrationWorkerLaunchReceipt effects?: unknown[] residualResources?: unknown[] failedStage?: string lastError?: string } -function validateRemoteWorkerStart(params: WorkerStartInput, createsWorktree: boolean): void { - if (createsWorktree && (!params.name || !params.repo)) { - throw new OrchestrationError( - 'invalid_argument', - 'Remote new-top-level requires --name and an explicit --repo from remote discovery.' - ) - } - if (createsWorktree && params.terminal) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal cannot combine with remote new-worktree creation.' - ) - } - if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { - throw new OrchestrationError( - 'invalid_argument', - 'Creation and setup options apply only to remote new-top-level worktrees.' - ) - } - if (params.terminal && params.agent) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal reuses an existing agent and cannot combine with --agent.' - ) - } - if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) { - throw new OrchestrationError( - 'agent_unconfigured', - 'A configured --agent is required when remote worker-start creates a terminal.' - ) - } -} - function isKnownRemoteStartFailure(code: string): boolean { return [ 'invalid_argument', @@ -272,7 +271,8 @@ function isKnownRemoteStartFailure(code: string): boolean { function federatedUnknownReceipt( worker: { dispatch_id: string; state: string; stage: string; last_error: string | null }, taskId: string, - serverName: string + serverName: string, + launch: OrchestrationWorkerLaunchReceipt ): unknown { return { taskId, @@ -280,6 +280,7 @@ function federatedUnknownReceipt( state: 'outcome_unknown', stage: worker.stage, server: { name: serverName }, + launch, failedStage: worker.stage, lastError: worker.last_error, effects: [], diff --git a/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts index 15b2194140a..486821231e1 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-agent-launch.test.ts @@ -61,7 +61,9 @@ describe('federated worker agent launch', () => { taskSpec: 'remote cursor worker', protocolVersion: 1, worktree: 'id:repo::remote-worktree', - agent: 'cursor' + agent: 'cursor', + model: 'gpt-5.3-codex', + effort: 'high' }), { runtime, @@ -72,14 +74,28 @@ describe('federated worker agent launch', () => { payloadHash: 'remote_payload' } } - )) as { state: string; failedStage?: string; lastError?: string } + )) as { + state: string + failedStage?: string + lastError?: string + launch: unknown + } // Why: assert the worker actually reached ready — a spy-only assertion would // stay green even if every stage after terminal_create regressed. - expect(result).toMatchObject({ state: 'ready' }) + expect(result).toMatchObject({ + state: 'ready', + launch: { + requested: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' }, + effective: { agent: 'cursor', model: 'gpt-5.3-codex', effort: 'high' } + } + }) expect(createTerminal).toHaveBeenCalledWith( 'id:repo::remote-worktree', - expect.objectContaining({ startupAgent: 'cursor' }) + expect.objectContaining({ + startupAgent: 'cursor', + launchPreferences: { model: 'gpt-5.3-codex', effort: 'high' } + }) ) expect(createTerminal).toHaveBeenCalledWith( 'id:repo::remote-worktree', diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts index b7e2da76531..fa9b60facba 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts @@ -1,6 +1,7 @@ import type { OrchestrationDb } from '../../orchestration/db' import { isFederationEffectUnknown } from './orchestration-federation-effects' import type { WorkerSetupReceipt } from './orchestration-worker-topology' +import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' export function failFederatedAttachmentWithReceipt(args: { db: OrchestrationDb @@ -9,6 +10,7 @@ export function failFederatedAttachmentWithReceipt(args: { failedStage: string error: unknown setup: WorkerSetupReceipt + launch: OrchestrationWorkerLaunchReceipt }): unknown { const reason = args.error instanceof Error ? args.error.message : String(args.error) const unknown = isFederationEffectUnknown(args.error, args.failedStage) @@ -26,6 +28,7 @@ export function failFederatedAttachmentWithReceipt(args: { failedStage: args.failedStage, lastError: reason, setup: args.setup, + launch: args.launch, effects: JSON.parse(attachment.effects) as unknown[], residualResources: JSON.parse(attachment.residual_resources) as unknown[] } diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts index a657ff7d09c..e6bea35c20a 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { OptionalWorkerLaunchPreference } from './orchestration-worker-start-schema' export const FederationAttachStartParams = z.object({ dispatchId: requiredString('Missing Dispatch ID'), @@ -16,6 +17,10 @@ export const FederationAttachStartParams = z.object({ setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(), terminal: OptionalString, agent: OptionalString, + model: OptionalWorkerLaunchPreference, + effort: OptionalWorkerLaunchPreference, timeoutMs: OptionalFiniteNumber, devMode: z.boolean().optional() }) + +export type FederationAttachStartInput = z.infer diff --git a/src/main/runtime/rpc/methods/orchestration-federation-test-request.ts b/src/main/runtime/rpc/methods/orchestration-federation-test-request.ts new file mode 100644 index 00000000000..7d004775310 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-test-request.ts @@ -0,0 +1,25 @@ +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import type { RpcRequest } from '../core' + +export function createFederationWorkerStartRequest( + taskId: string, + overrides: Record = {} +): RpcRequest { + return { + id: 'rpc_worker_start', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'request_windows_worker', + method: 'orchestration.workerStart', + params: { + task: taskId, + from: 'term_coord', + on: 'windows', + worktree: 'new-top-level', + repo: 'id:windows-repo', + name: 'windows-audit', + agent: 'codex', + ...overrides + } + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federation.test.ts b/src/main/runtime/rpc/methods/orchestration-federation.test.ts index cf9224c875c..e797a09c256 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.test.ts @@ -8,8 +8,8 @@ import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport' import { RpcDispatcher } from '../dispatcher' -import type { RpcRequest } from '../core' import { ORCHESTRATION_METHODS } from './orchestration' +import { createFederationWorkerStartRequest as startRequest } from './orchestration-federation-test-request' describe('orchestration federation', () => { const databases: OrchestrationDb[] = [] @@ -97,26 +97,6 @@ describe('orchestration federation', () => { return homeDb.createTask({ spec: 'Audit Windows behavior', runId: run.id }) } - function startRequest(taskId: string, overrides: Record = {}): RpcRequest { - return { - id: 'rpc_worker_start', - authToken: 'coordinator-token', - orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, - orchestrationRequestId: 'request_windows_worker', - method: 'orchestration.workerStart', - params: { - task: taskId, - from: 'term_coord', - on: 'windows', - worktree: 'new-top-level', - repo: 'id:windows-repo', - name: 'windows-audit', - agent: 'codex', - ...overrides - } - } - } - function configureWorkerRuntime(runtime: OrcaRuntimeService): void { vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) vi.spyOn(runtime, 'showRepo').mockResolvedValue({ @@ -230,6 +210,25 @@ describe('orchestration federation', () => { ) }) + it('does not report remotely rejected preferences as effective', async () => { + const task = createHomeTask() + + const response = await homeDispatcher.dispatch( + startRequest(task.id, { agent: 'grok', model: 'unsupported-model' }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { + state: 'failed', + launch: { + requested: { agent: 'grok', model: 'unsupported-model', effort: null }, + effective: null + } + } + }) + }) + it('preserves wait-for-setup gating on the connected worker server', async () => { vi.mocked(workerRuntime.createManagedWorktree).mockResolvedValueOnce({ worktree: { id: 'repo::windows-worktree', repoId: 'repo' }, diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts index ce173290848..ffde742ecee 100644 --- a/src/main/runtime/rpc/methods/orchestration-federation.ts +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -1,4 +1,3 @@ -import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { buildDispatchPreamble } from '../../orchestration/preamble' import { OrchestrationError } from '../../orchestration/orchestration-error' @@ -18,6 +17,7 @@ import { } from './orchestration-federation-setup' import { FederationAttachStartParams } from './orchestration-federation-start-schema' import { failFederatedAttachmentWithReceipt } from './orchestration-federation-start-receipt' +import { prepareFederationAttachmentWorkerStart } from './orchestration-worker-start-validation' export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ defineMethod({ @@ -37,43 +37,11 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ ) } const createsWorktree = params.worktree === 'new-top-level' - if (createsWorktree && (!params.name || !params.repo)) { - throw new OrchestrationError( - 'invalid_argument', - 'A remote new-top-level worktree requires --name and an explicit --repo.' - ) - } - if (createsWorktree && params.terminal) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal cannot combine with remote new-worktree creation.' - ) - } - if ( - !createsWorktree && - (params.name || params.repo || params.baseBranch || params.setup || params.setupSource) - ) { - throw new OrchestrationError( - 'invalid_argument', - 'Creation and setup options apply only to remote new-top-level worktrees.' - ) - } - if (params.terminal && params.agent) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal reuses an existing agent and cannot combine with --agent.' - ) - } - const agent = params.agent - if (!params.terminal && (!agent || !isTuiAgent(agent))) { - throw new OrchestrationError( - 'agent_unconfigured', - 'A configured --agent is required when federated worker-start creates a terminal.' - ) - } - if (agent) { - runtime.validateOrchestrationAgentLauncher(agent as TuiAgent) - } + const { agent, launch } = prepareFederationAttachmentWorkerStart({ + params, + createsWorktree, + runtime + }) if (createsWorktree) { await assertOrchestrationWorktreeCreationSupported({ runtime, @@ -126,6 +94,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ observeSetupCompletion: true, createdWithAgent: agent as TuiAgent, startupAgent: agent as TuiAgent, + ...(launch.preferences ? { startupLaunchPreferences: launch.preferences } : {}), activate: false, lineage: { noParent: true } }) @@ -196,6 +165,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ // Why: agent ids are not shell commands (`cursor` is the desktop app, // its CLI is `cursor-agent`); resolve through the TUI agent config. startupAgent: agent as TuiAgent, + ...(launch.preferences ? { launchPreferences: launch.preferences } : {}), title: `worker-${params.taskId}`, presentation: 'background' }) @@ -284,6 +254,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ worktreeId: worktree.id, terminalHandle, setup, + launch: launch.receipt, effects, residualResources: [] } @@ -294,7 +265,8 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ runtimeEpoch: runtime.getRuntimeId(), failedStage, error, - setup + setup, + launch: launch.receipt }) } } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts new file mode 100644 index 00000000000..dd17945445c --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + assertWorkerLaunchPreferencesCreateTerminal, + assertWorkerLaunchPreferencesRuntimeSupported, + createPendingWorkerLaunchReceipt, + resolveFederatedWorkerLaunchReceipt, + resolveWorkerLaunchPreferences +} from './orchestration-worker-launch-preferences' +import { WorkerStartParams } from './orchestration-worker-start-schema' + +describe('orchestration worker launch preferences', () => { + it('passes an opaque Claude model and portable effort through the shared catalog', () => { + expect( + resolveWorkerLaunchPreferences({ + agent: 'claude', + model: 'aws-bedrock-opus-5', + effort: 'high' + }) + ).toEqual({ + preferences: { model: 'aws-bedrock-opus-5', effort: 'high' }, + receipt: { + requested: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' }, + effective: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' } + } + }) + }) + + it('does not invent an effort when only a model is requested', () => { + expect( + resolveWorkerLaunchPreferences({ agent: 'codex', model: 'gpt-5.6-sol' }).preferences + ).toEqual({ model: 'gpt-5.6-sol' }) + }) + + it('rejects model-specific effort combinations the catalog disproves', () => { + expect(() => + resolveWorkerLaunchPreferences({ + agent: 'codex', + model: 'gpt-5.6-luna', + effort: 'xhigh' + }) + ).toThrow('does not support effort xhigh') + }) + + it('rejects effort without a model', () => { + expect(() => resolveWorkerLaunchPreferences({ agent: 'codex', effort: 'high' })).toThrow( + '--effort requires --model' + ) + }) + + it('rejects model selection for agents without a launch catalog', () => { + expect(() => + resolveWorkerLaunchPreferences({ agent: 'grok', model: 'grok-code-fast-1' }) + ).toThrow('does not support launch-time model selection') + }) + + it('does not expose deprecated Gemini model selection to worker-start', () => { + expect(() => + resolveWorkerLaunchPreferences({ agent: 'gemini', model: 'gemini-3-pro-preview' }) + ).toThrow('does not support launch-time model selection') + }) + + it('rejects preferences when reusing an existing terminal', () => { + expect(() => + assertWorkerLaunchPreferencesCreateTerminal({ + terminal: 'term_existing', + model: 'gpt-5.6-sol' + }) + ).toThrow('cannot be applied when reusing an existing terminal') + }) + + it('requires remote capability support only for explicit preferences', () => { + expect(() => + assertWorkerLaunchPreferencesRuntimeSupported({ + model: 'gpt-5.6-sol', + capabilities: [], + serverName: 'windows' + }) + ).toThrow('does not support worker model or effort overrides') + expect(() => + assertWorkerLaunchPreferencesRuntimeSupported({ + capabilities: [], + serverName: 'windows' + }) + ).not.toThrow() + expect(() => + assertWorkerLaunchPreferencesRuntimeSupported({ + model: 'gpt-5.6-sol', + capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY], + serverName: 'windows' + }) + ).not.toThrow() + }) + + it('uses the requested launch receipt when an older worker omits it', () => { + const requested = createPendingWorkerLaunchReceipt({ + agent: 'codex', + model: 'gpt-5.6-sol', + effort: 'high' + }) + + expect(resolveFederatedWorkerLaunchReceipt(undefined, requested, true)).toEqual({ + requested: requested.requested, + effective: requested.requested + }) + expect(resolveFederatedWorkerLaunchReceipt(undefined, requested, false)).toBe(requested) + }) + + it.each([' custom-model', 'custom-model '])( + 'rejects model ids with surrounding whitespace: %j', + (model) => { + expect(WorkerStartParams.safeParse({ task: 'task_1', agent: 'codex', model }).success).toBe( + false + ) + } + ) + + it('bounds opaque launch preferences', () => { + expect( + WorkerStartParams.safeParse({ + task: 'task_1', + agent: 'codex', + model: 'm'.repeat(513) + }).success + ).toBe(false) + expect( + WorkerStartParams.safeParse({ + task: 'task_1', + agent: 'codex', + model: 'custom-model', + effort: 'e'.repeat(513) + }).success + ).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts new file mode 100644 index 00000000000..76c60064691 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-launch-preferences.ts @@ -0,0 +1,158 @@ +import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority' +import { + findCatalogModel, + findCatalogOption, + getAgentSessionOptionCatalog +} from '../../../../shared/agent-session-option-catalog' +import { resolveAgentSessionOptionLaunch } from '../../../../shared/agent-session-option-launch' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { TuiAgent } from '../../../../shared/types' +import { OrchestrationError } from '../../orchestration/orchestration-error' + +export type OrchestrationWorkerLaunchSelection = { + agent: TuiAgent | null + model: string | null + effort: string | null +} + +export type OrchestrationWorkerLaunchReceipt = { + requested: OrchestrationWorkerLaunchSelection + effective: OrchestrationWorkerLaunchSelection | null +} + +export function createWorkerLaunchReceipt(args: { + agent: TuiAgent | null + model?: string + effort?: string +}): OrchestrationWorkerLaunchReceipt { + const selection = { + agent: args.agent, + model: args.model ?? null, + effort: args.effort ?? null + } + return { requested: selection, effective: { ...selection } } +} + +export function createPendingWorkerLaunchReceipt(args: { + agent: TuiAgent | null + model?: string + effort?: string +}): OrchestrationWorkerLaunchReceipt { + return { + requested: { + agent: args.agent, + model: args.model ?? null, + effort: args.effort ?? null + }, + effective: null + } +} + +export function resolveWorkerLaunchPreferences(args: { + agent: TuiAgent + model?: string + effort?: string +}): { + preferences: AgentLaunchPreferences | undefined + receipt: OrchestrationWorkerLaunchReceipt +} { + if (args.effort && !args.model) { + throw new OrchestrationError('invalid_argument', '--effort requires --model.') + } + if (!args.model) { + return { + preferences: undefined, + receipt: createWorkerLaunchReceipt({ agent: args.agent }) + } + } + + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) { + throw new OrchestrationError( + 'invalid_argument', + `Agent ${args.agent} does not support launch-time model selection.` + ) + } + + if (args.effort) { + const model = findCatalogModel(catalog, args.model) + const option = + findCatalogOption(model, 'effort') ?? + (!model + ? catalog.unknownModelOptions?.find((candidate) => candidate.id === 'effort') + : undefined) + if ( + option?.kind.type !== 'select' || + !option.kind.choices.some((choice) => choice.value === args.effort) + ) { + throw new OrchestrationError( + 'invalid_argument', + `Agent ${args.agent} model ${args.model} does not support effort ${args.effort}.` + ) + } + } + + const requested = { + model: args.model, + ...(args.effort ? { effort: args.effort } : {}) + } + const resolved = resolveAgentSessionOptionLaunch(args.agent, requested, [], false) + if ( + resolved.appliedValues.model !== args.model || + resolved.appliedValues.effort !== args.effort + ) { + throw new OrchestrationError( + 'invalid_argument', + `Agent ${args.agent} cannot apply the requested worker launch preferences.` + ) + } + + const preferences: AgentLaunchPreferences = requested + return { + preferences, + receipt: createWorkerLaunchReceipt({ agent: args.agent, ...preferences }) + } +} + +export function assertWorkerLaunchPreferencesCreateTerminal(args: { + terminal?: string + model?: string + effort?: string +}): void { + if (args.terminal && (args.model || args.effort)) { + throw new OrchestrationError( + 'invalid_argument', + '--model and --effort cannot be applied when reusing an existing terminal.' + ) + } +} + +export function assertWorkerLaunchPreferencesRuntimeSupported(args: { + model?: string + effort?: string + capabilities?: readonly string[] + serverName: string +}): void { + if ( + (args.model || args.effort) && + !args.capabilities?.includes(ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY) + ) { + throw new OrchestrationError( + 'capability_unsupported', + `Connected server ${args.serverName} does not support worker model or effort overrides.` + ) + } +} + +export function resolveFederatedWorkerLaunchReceipt( + remote: OrchestrationWorkerLaunchReceipt | undefined, + requested: OrchestrationWorkerLaunchReceipt, + remoteReady: boolean +): OrchestrationWorkerLaunchReceipt { + if (remote) { + return remote + } + return remoteReady + ? { requested: requested.requested, effective: { ...requested.requested } } + : requested +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts index 167d4610984..e58f9fff01e 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts @@ -3,6 +3,7 @@ import { isUnknownWorkerStartOutcome, type WorkerSetupReceipt } from './orchestration-worker-topology' +import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' export function failWorkerStartWithReceipt(args: { db: OrchestrationDb @@ -12,6 +13,7 @@ export function failWorkerStartWithReceipt(args: { failedStage: string error: unknown setup: WorkerSetupReceipt + launch: OrchestrationWorkerLaunchReceipt }): unknown { const reason = args.error instanceof Error ? args.error.message : String(args.error) const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage) @@ -27,6 +29,7 @@ export function failWorkerStartWithReceipt(args: { failedStage: args.failedStage, lastError: reason, setup: args.setup, + launch: args.launch, effects: JSON.parse(worker.effects) as unknown[], residualResources: JSON.parse(worker.residual_resources) as unknown[], ...(unknown diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts index d577598c966..cf081f9b36c 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts @@ -1,6 +1,13 @@ import { z } from 'zod' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +export const OptionalWorkerLaunchPreference = z + .string() + .min(1) + .max(512) + .refine((value) => value === value.trim(), 'Surrounding whitespace is invalid') + .optional() + export const WorkerStartParams = z.object({ task: requiredString('Missing --task'), on: OptionalString, @@ -15,6 +22,8 @@ export const WorkerStartParams = z.object({ setup: z.enum(['run', 'skip', 'inherit']).optional(), terminal: OptionalString, agent: OptionalString, + model: OptionalWorkerLaunchPreference, + effort: OptionalWorkerLaunchPreference, retryOf: OptionalString, timeoutMs: OptionalFiniteNumber, devMode: z.boolean().optional() diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts new file mode 100644 index 00000000000..f6bb103f37e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-validation.ts @@ -0,0 +1,164 @@ +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../../shared/types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { FederationAttachStartInput } from './orchestration-federation-start-schema' +import { + assertWorkerLaunchPreferencesCreateTerminal, + createWorkerLaunchReceipt, + resolveWorkerLaunchPreferences +} from './orchestration-worker-launch-preferences' +import type { WorkerStartInput } from './orchestration-worker-start-schema' + +type WorkerStartLaunch = ReturnType + +export function validateFederatedWorkerStartPlacement( + params: WorkerStartInput, + createsWorktree: boolean +): void { + if (createsWorktree && (!params.name || !params.repo)) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote new-top-level requires --name and an explicit --repo from remote discovery.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with remote new-worktree creation.' + ) + } + if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to remote new-top-level worktrees.' + ) + } + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) { + throw new OrchestrationError( + 'agent_unconfigured', + 'A configured --agent is required when remote worker-start creates a terminal.' + ) + } +} + +export function prepareLocalWorkerStart(args: { + params: WorkerStartInput + createsWorktree: boolean + runtime: OrcaRuntimeService +}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { + const { params, createsWorktree, runtime } = args + assertWorkerLaunchPreferencesCreateTerminal(params) + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with new-worktree creation.' + ) + } + if (createsWorktree && !params.name) { + throw new OrchestrationError('invalid_argument', 'New worktrees require --name.') + } + if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to new-child or new-top-level worktrees.' + ) + } + return resolveWorkerStartAgent({ + runtime, + terminal: params.terminal, + agent: params.agent, + model: params.model, + effort: params.effort, + missingAgentMessage: 'A configured --agent is required when worker-start creates a terminal.' + }) +} + +export function prepareFederationAttachmentWorkerStart(args: { + params: FederationAttachStartInput + createsWorktree: boolean + runtime: OrcaRuntimeService +}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { + const { params, createsWorktree, runtime } = args + assertWorkerLaunchPreferencesCreateTerminal(params) + if (createsWorktree && (!params.name || !params.repo)) { + throw new OrchestrationError( + 'invalid_argument', + 'A remote new-top-level worktree requires --name and an explicit --repo.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with remote new-worktree creation.' + ) + } + if ( + !createsWorktree && + (params.name || params.repo || params.baseBranch || params.setup || params.setupSource) + ) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to remote new-top-level worktrees.' + ) + } + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + return resolveWorkerStartAgent({ + runtime, + terminal: params.terminal, + agent: params.agent, + model: params.model, + effort: params.effort, + missingAgentMessage: + 'A configured --agent is required when federated worker-start creates a terminal.' + }) +} + +function resolveWorkerStartAgent(args: { + runtime: OrcaRuntimeService + terminal?: string + agent?: string + model?: string + effort?: string + missingAgentMessage: string +}): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { + if (!args.terminal && (!args.agent || !isTuiAgent(args.agent))) { + throw new OrchestrationError('agent_unconfigured', args.missingAgentMessage) + } + const agent = args.agent as TuiAgent | undefined + if (agent) { + args.runtime.validateOrchestrationAgentLauncher(agent) + return { + agent, + launch: resolveWorkerLaunchPreferences({ + agent, + model: args.model, + effort: args.effort + }) + } + } + return { + agent: undefined, + launch: { + preferences: undefined, + receipt: createWorkerLaunchReceipt({ agent: null }) + } + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index 122a5b869ba..9672560ab1e 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -1,3 +1,4 @@ +import type { AgentLaunchPreferences } from '../../../../shared/agent-session-host-authority' import type { TuiAgent } from '../../../../shared/types' import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' @@ -56,6 +57,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { runtime: OrcaRuntimeService worktreeId: string agent: TuiAgent + launchPreferences?: AgentLaunchPreferences taskId: string effects: WorkerEffect[] }): Promise<{ handle: string; warning?: string }> { @@ -64,6 +66,7 @@ export async function createExistingWorktreeWorkerTerminal(args: { // desktop app while its CLI is `cursor-agent`. Let the runtime build the // configured launcher instead of executing the raw id. startupAgent: args.agent, + ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), title: `worker-${args.taskId}`, // Why: dispatching a worker is background work; it must not pull the sidebar // to the worker's workspace while the user is reading somewhere else. @@ -117,6 +120,7 @@ export async function createWorkerWorktree(args: { from: string } agent: TuiAgent + launchPreferences?: AgentLaunchPreferences effects: WorkerEffect[] }): Promise<{ worktree: Awaited> @@ -139,6 +143,7 @@ export async function createWorkerWorktree(args: { observeSetupCompletion: true, createdWithAgent: args.agent, startupAgent: args.agent, + ...(args.launchPreferences ? { startupLaunchPreferences: args.launchPreferences } : {}), activate: false, lineage: { parentWorktree: requestedWorktree === 'new-child' ? coordinatorWorktree.id : undefined, diff --git a/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts index 8e0a164178b..b83ab5b087b 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts @@ -157,6 +157,29 @@ describe('orchestration new-worktree workers', () => { expect(runtime.createTerminal).not.toHaveBeenCalled() }) + it('passes launch preferences into agent-first worktree creation', async () => { + mockCreatedWorktree() + + const { result } = await startWorker({ + model: 'custom-codex-model', + effort: 'high' + }) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + startupAgent: 'codex', + startupLaunchPreferences: { model: 'custom-codex-model', effort: 'high' } + }) + ) + expect(result).toMatchObject({ + state: 'ready', + launch: { + requested: { agent: 'codex', model: 'custom-codex-model', effort: 'high' }, + effective: { agent: 'codex', model: 'custom-codex-model', effort: 'high' } + } + }) + }) + it('rejects a new worktree for a folder project before creating effects', async () => { vi.mocked(runtime.showRepo).mockResolvedValue({ id: 'repo', diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index dc659773a8a..229253ec618 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -1,4 +1,3 @@ -import { isTuiAgent } from '../../../../shared/tui-agent-config' import type { TuiAgent } from '../../../../shared/types' import { buildDispatchPreamble } from '../../orchestration/preamble' import { OrchestrationError } from '../../orchestration/orchestration-error' @@ -20,6 +19,7 @@ import { persistWorkerSetupWaitOutcome } from './orchestration-worker-setup-gate' import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt' +import { prepareLocalWorkerStart } from './orchestration-worker-start-validation' export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ defineMethod({ @@ -57,37 +57,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' - if (params.terminal && params.agent) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal reuses an existing agent and cannot combine with --agent.' - ) - } - if (createsWorktree && params.terminal) { - throw new OrchestrationError( - 'invalid_argument', - '--terminal cannot combine with new-worktree creation.' - ) - } - if (createsWorktree && !params.name) { - throw new OrchestrationError('invalid_argument', 'New worktrees require --name.') - } - if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { - throw new OrchestrationError( - 'invalid_argument', - 'Creation and setup options apply only to new-child or new-top-level worktrees.' - ) - } - const agent = params.agent - if (!params.terminal && (!agent || !isTuiAgent(agent))) { - throw new OrchestrationError( - 'agent_unconfigured', - 'A configured --agent is required when worker-start creates a terminal.' - ) - } - if (agent) { - runtime.validateOrchestrationAgentLauncher(agent as TuiAgent) - } + const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) const coordinatorTerminal = await runtime.showTerminal(params.from) const coordinatorWorktree = await runtime.showManagedWorktree( @@ -130,6 +100,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ baseBranch: params.baseBranch ?? null, terminal: params.terminal ?? null, agent: agent ?? null, + launch: launch.receipt, timeoutMs: params.timeoutMs ?? 60_000, setup: createsWorktree ? (params.setup ?? 'run') : 'not_applicable', setupSource: createsWorktree @@ -174,6 +145,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ coordinatorWorktree, params, agent: agent as TuiAgent, + launchPreferences: launch.preferences, effects }) resolvedWorktree = created.worktree @@ -190,6 +162,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ runtime, worktreeId: resolvedWorktree!.id, agent: agent as TuiAgent, + launchPreferences: launch.preferences, taskId: task.id, effects }) @@ -281,6 +254,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ state: worker.state, stage: worker.stage, setup: setupReceipt, + launch: launch.receipt, timeoutMs: params.timeoutMs ?? 60_000, effects, residualResources: [], @@ -294,7 +268,8 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ dispatchId: started.dispatch.id, failedStage, error, - setup: setupReceipt + setup: setupReceipt, + launch: launch.receipt }) } } diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 8ba154c48f7..a90bbe80410 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -2235,6 +2235,61 @@ describe('orchestration RPC methods', () => { ) }) + it('applies and reports opaque per-invocation model preferences', async () => { + setup() + mockCurrentWorkerStart() + const task = db.createTask({ spec: 'launch a custom model' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'claude', + model: 'aws-bedrock-opus-5', + effort: 'high' + })) as { + dispatchId: string + state: string + launch: { + requested: { agent: string; model: string; effort: string } + effective: { agent: string; model: string; effort: string } + } + } + + expect(result).toMatchObject({ + state: 'ready', + launch: { + requested: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' }, + effective: { agent: 'claude', model: 'aws-bedrock-opus-5', effort: 'high' } + } + }) + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ + startupAgent: 'claude', + launchPreferences: { model: 'aws-bedrock-opus-5', effort: 'high' } + }) + ) + expect(JSON.parse(db.getWorkerDispatch(result.dispatchId)!.start_options)).toMatchObject({ + launch: result.launch + }) + }) + + it('rejects launch preferences for an existing terminal before creating a Dispatch', async () => { + setup() + mockCurrentWorkerStart() + const task = db.createTask({ spec: 'reuse exact worker' }) + + await expect( + call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + terminal: 'term_worker', + model: 'gpt-5.6-sol' + }) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + expect(db.getDispatchContext(task.id)).toBeUndefined() + }) + // Why: `cursor` on PATH is the Cursor desktop app; passing the agent id as a // shell command opened the IDE and left a blank shell (issue #11926). it('never passes the agent id to the worker terminal as a shell command', async () => { diff --git a/src/shared/agent-session-option-agent-args.ts b/src/shared/agent-session-option-agent-args.ts new file mode 100644 index 00000000000..d357ea57b73 --- /dev/null +++ b/src/shared/agent-session-option-agent-args.ts @@ -0,0 +1,35 @@ +export function agentArgOptionTokens(tokens: readonly string[]): readonly string[] { + const terminator = tokens.indexOf('--') + return terminator === -1 ? tokens : tokens.slice(0, terminator) +} + +export function removeAgentArgOption( + tokens: readonly string[], + aliases: readonly string[] +): string[] { + const result: string[] = [] + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] + if (token === '--') { + result.push(...tokens.slice(index)) + break + } + const exact = aliases.includes(token) + const matched = aliases.some( + (alias) => + token.startsWith(`${alias}=`) || + (alias.startsWith('-') && + !alias.startsWith('--') && + token.startsWith(alias) && + token.length > alias.length) + ) + if (!exact && !matched) { + result.push(token) + continue + } + if (exact && tokens[index + 1] && !tokens[index + 1].startsWith('-')) { + index += 1 + } + } + return result +} diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index ab13dda4dc5..0464fd1f131 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -3,6 +3,7 @@ import type { CatalogModel, CatalogOption } from './agent-session-option-catalog-types' +import { agentArgOptionTokens, removeAgentArgOption } from './agent-session-option-agent-args' import { CLAUDE_MODEL_LIST_ARGS, CLAUDE_MODEL_LIST_STDIN, @@ -10,7 +11,7 @@ import { } from './claude-model-list-probe' function hasFlag(tokens: readonly string[], flags: readonly string[]): boolean { - return tokens.some((token) => + return agentArgOptionTokens(tokens).some((token) => flags.some( (flag) => token === flag || @@ -24,8 +25,9 @@ function hasCodexEffortOverride(tokens: readonly string[]): boolean { if (hasFlag(tokens, ['--reasoning-effort'])) { return true } - return tokens.some((token, index) => { - const previous = tokens[index - 1] + const optionTokens = agentArgOptionTokens(tokens) + return optionTokens.some((token, index) => { + const previous = optionTokens[index - 1] return ( (token.startsWith('model_reasoning_effort=') && (previous === '-c' || previous === '--config')) || @@ -36,6 +38,32 @@ function hasCodexEffortOverride(tokens: readonly string[]): boolean { }) } +function removeCodexEffortOverride(tokens: readonly string[]): string[] { + const withoutFlag = removeAgentArgOption(tokens, ['--reasoning-effort']) + const result: string[] = [] + for (let index = 0; index < withoutFlag.length; index += 1) { + const token = withoutFlag[index] + if (token === '--') { + result.push(...withoutFlag.slice(index)) + break + } + const next = withoutFlag[index + 1] + if ((token === '-c' || token === '--config') && next?.startsWith('model_reasoning_effort=')) { + index += 1 + continue + } + if ( + token.startsWith('-cmodel_reasoning_effort=') || + token.startsWith('-c=model_reasoning_effort=') || + token.startsWith('--config=model_reasoning_effort=') + ) { + continue + } + result.push(token) + } + return result +} + const STANDARD_EFFORT_CHOICES = [ { value: 'low', label: 'Low' }, { value: 'medium', label: 'Medium' }, @@ -67,6 +95,7 @@ function claudeEffortWithChoices(choices: typeof EXTENDED_EFFORT_CHOICES): Catal apply: { launchArgs: (value) => ['--effort', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']), midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } } @@ -108,6 +137,7 @@ const CLAUDE_FAST_MODE: CatalogOption = { } export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, // Why: these ids are Claude CLI aliases that resolve to the newest model of // each family on the host's CLI (`opus` is Opus 5 on current CLIs, older // Opus on older CLIs), so pinned version labels lie on part of the fleet. @@ -143,6 +173,7 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { modelApply: { launchArgs: (value) => ['--model', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']), midSession: { kind: 'command', build: (value) => `/model ${String(value)}`, @@ -152,6 +183,7 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { detectAgentInteraction: 'claude-model-switch-confirmation' } }, + unknownModelOptions: [claudeEffort(true)], listModels: { command: `echo '${CLAUDE_MODEL_LIST_STDIN.trim()}' | claude ${CLAUDE_MODEL_LIST_ARGS.join(' ')}`, parse: parseClaudeCatalogModels @@ -181,12 +213,14 @@ function codexEffort(includeExtraHigh: boolean): CatalogOption { apply: { launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], agentArgsOverride: hasCodexEffortOverride, + removeAgentArgs: removeCodexEffortOverride, midSession: { kind: 'agent-picker', command: '/model' } } } } export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, // Why: Codex model access depends on auth. Keep this seed short and allow // unknown persisted ids to pass through instead of claiming a complete list. models: [ @@ -203,11 +237,13 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { modelApply: { launchArgs: (value) => ['-m', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), // Codex accepts a model argument in its live /model command. midSession: { kind: 'command', build: (value) => `/model ${String(value)}`, pickerCommand: '/model' } - } + }, + unknownModelOptions: [codexEffort(true)] } diff --git a/src/shared/agent-session-option-catalog-gemini-cursor.ts b/src/shared/agent-session-option-catalog-gemini-cursor.ts index b655c8e0637..c4f818ad549 100644 --- a/src/shared/agent-session-option-catalog-gemini-cursor.ts +++ b/src/shared/agent-session-option-catalog-gemini-cursor.ts @@ -3,9 +3,10 @@ import type { CatalogModel, CatalogOption } from './agent-session-option-catalog-types' +import { agentArgOptionTokens, removeAgentArgOption } from './agent-session-option-agent-args' function hasModelFlag(tokens: readonly string[]): boolean { - return tokens.some( + return agentArgOptionTokens(tokens).some( (token) => token === '-m' || token === '--model' || @@ -77,6 +78,7 @@ function parseCursorModels(stdout: string): CatalogModel[] { } export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, models: [ { id: 'auto', label: 'Auto', isDefault: true, options: [] }, { @@ -93,6 +95,7 @@ export const CURSOR_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { modelApply: { launchArgs: (value) => ['--model', String(value)], agentArgsOverride: hasModelFlag, + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), midSession: { kind: 'command', build: (value) => `/model ${String(value)}` } }, composeModelValue: (modelId, values) => { diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index a416b10913e..5475910702b 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -23,6 +23,8 @@ export type CatalogOptionApply = { /** Why: later free-form args win, so the launch record must discard any * picker value that those args may have replaced. */ agentArgsOverride?: (tokens: readonly string[]) => boolean + /** Removes conflicting defaults before a more specific launch choice is inserted. */ + removeAgentArgs?: (tokens: readonly string[]) => string[] composedIntoModel?: true midSession?: CatalogMidSessionApply } @@ -53,6 +55,10 @@ export type CatalogModel = { export type AgentSessionOptionCatalog = { models: CatalogModel[] modelApply: CatalogOptionApply + /** Opts this agent into structured per-worker launch overrides. */ + supportsWorkerLaunchPreferences?: true + /** Launch-safe options for opaque model ids that are absent from the static catalog. */ + unknownModelOptions?: CatalogOption[] composeModelValue?: (modelId: string, values: Record) => string listModels?: { command: string diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index 9181039f0a7..53df967a838 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -7,10 +7,32 @@ export type ResolvedSessionOptionLaunch = { appliedValues: Record } +export function removeOverriddenAgentSessionArgs( + agent: AgentType, + values: Record | null | undefined, + tokens: readonly string[] +): string[] { + const catalog = getAgentSessionOptionCatalog(agent) + const modelId = typeof values?.model === 'string' ? values.model : null + if (!catalog || !values || !modelId) { + return [...tokens] + } + let result = catalog.modelApply.removeAgentArgs?.(tokens) ?? [...tokens] + const model = findCatalogModel(catalog, modelId) + const modelOptions = model?.options ?? catalog.unknownModelOptions ?? [] + for (const option of modelOptions) { + if (values[option.id] !== undefined && option.apply.removeAgentArgs) { + result = option.apply.removeAgentArgs(result) + } + } + return result +} + export function resolveAgentSessionOptionLaunch( agent: AgentType, values: Record | null | undefined, - trailingAgentArgs: readonly string[] = [] + trailingAgentArgs: readonly string[] = [], + includeCatalogDefaults = true ): ResolvedSessionOptionLaunch { const catalog = getAgentSessionOptionCatalog(agent) const modelId = typeof values?.model === 'string' ? values.model : null @@ -21,11 +43,23 @@ export function resolveAgentSessionOptionLaunch( const model = findCatalogModel(catalog, modelId) const appliedValues: Record = {} const args: string[] = [] - const modelValues = model - ? Object.fromEntries( - model.options.map((option) => [option.id, values[option.id] ?? option.kind.defaultValue]) - ) - : {} + const modelOptions = model?.options ?? catalog.unknownModelOptions ?? [] + const modelValues = Object.fromEntries( + modelOptions.flatMap((option) => { + const explicitValue = values[option.id] + if (explicitValue !== undefined) { + if ( + !model && + option.kind.type === 'select' && + !option.kind.choices.some((choice) => choice.value === explicitValue) + ) { + return [] + } + return [[option.id, explicitValue]] + } + return model && includeCatalogDefaults ? [[option.id, option.kind.defaultValue]] : [] + }) + ) const composedModelId = catalog.composeModelValue ? catalog.composeModelValue(modelId, modelValues) : modelId @@ -37,11 +71,7 @@ export function resolveAgentSessionOptionLaunch( appliedValues.model = modelId } } - if (!model) { - return { args, appliedValues } - } - - for (const option of model.options) { + for (const option of modelOptions) { const value = modelValues[option.id] if (value === undefined) { continue diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index c2f005ddef1..3f39bf29e34 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -32,6 +32,8 @@ export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-c export const ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY = 'orchestration.federation.v1' as const export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY = 'orchestration.federation-control-mail.v1' as const +export const ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY = + 'orchestration.worker-launch-preferences.v1' as const export const ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION = 2 as const export const ORCHESTRATION_CONTRACT_VERSION = 1 as const export const ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY = 'orchestration.contract.v1' as const @@ -86,6 +88,7 @@ export const RUNTIME_CAPABILITIES = [ REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY, ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY, + ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY, ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, 'browser.screencast.v1', 'terminal.binary-stream.v1', diff --git a/src/shared/tui-agent-launch-command.ts b/src/shared/tui-agent-launch-command.ts index d433fc61721..3ce20571f4e 100644 --- a/src/shared/tui-agent-launch-command.ts +++ b/src/shared/tui-agent-launch-command.ts @@ -1,4 +1,7 @@ -import { resolveAgentSessionOptionLaunch } from './agent-session-option-launch' +import { + removeOverriddenAgentSessionArgs, + resolveAgentSessionOptionLaunch +} from './agent-session-option-launch' import type { SessionOptionValue } from './native-chat-session-options' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from './tui-agent-config' import { @@ -25,6 +28,7 @@ export function resolveAgentLaunchCommand(args: { shell: AgentStartupShell agentArgs?: string | null sessionOptions?: Record + sessionOptionsOverrideAgentArgs?: boolean isRemote?: boolean }): ResolvedAgentLaunchCommand { const override = args.cmdOverrides[args.agent] @@ -46,17 +50,60 @@ export function resolveAgentLaunchCommand(args: { const resolvedOptions = resolveAgentSessionOptionLaunch( args.agent, args.sessionOptions, - trailingTokens.tokens + args.sessionOptionsOverrideAgentArgs ? [] : trailingTokens.tokens, + !args.sessionOptionsOverrideAgentArgs ) + if (override && args.sessionOptionsOverrideAgentArgs) { + const overrideTokens = tokenizeStartupCommand(override, args.shell) + if (!overrideTokens.ok) { + return { ok: false, error: `Agent command override is invalid: ${overrideTokens.error}` } + } + const commandOverrideOptions = resolveAgentSessionOptionLaunch( + args.agent, + args.sessionOptions, + overrideTokens.tokens, + false + ) + if ( + Object.entries(resolvedOptions.appliedValues).some( + ([key, value]) => commandOverrideOptions.appliedValues[key] !== value + ) + ) { + return { + ok: false, + error: + 'Agent command override conflicts with the requested launch preferences. Remove model or effort flags from the command override.' + } + } + } const optionSuffix = resolvedOptions.args.map((arg) => quoteStartupArg(arg, args.shell)).join(' ') - const commandWithOptions = optionSuffix ? `${command} ${optionSuffix}` : command const commandWithoutSessionOptions = suffix.suffix ? `${command} ${suffix.suffix}` : command - // Why: session flags precede the free-form suffix so the user's explicit - // repeated flag remains the final, winning occurrence. + const commandWithOptions = optionSuffix ? `${command} ${optionSuffix}` : command + const overrideTokens = args.sessionOptionsOverrideAgentArgs + ? insertBeforeTerminator( + removeOverriddenAgentSessionArgs(args.agent, args.sessionOptions, trailingTokens.tokens), + resolvedOptions.args + ) + : [] + const commandWithOverrides = overrideTokens.length + ? `${command} ${overrideTokens.map((token) => quoteStartupArg(token, args.shell)).join(' ')}` + : command return { ok: true, - command: suffix.suffix ? `${commandWithOptions} ${suffix.suffix}` : commandWithOptions, + command: args.sessionOptionsOverrideAgentArgs + ? commandWithOverrides + : suffix.suffix + ? `${commandWithOptions} ${suffix.suffix}` + : commandWithOptions, commandWithoutSessionOptions, appliedSessionOptions: resolvedOptions.appliedValues } } + +function insertBeforeTerminator(tokens: readonly string[], inserted: readonly string[]): string[] { + const terminator = tokens.indexOf('--') + if (terminator === -1) { + return [...tokens, ...inserted] + } + return [...tokens.slice(0, terminator), ...inserted, ...tokens.slice(terminator)] +} diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts index af97826f530..cd47d81b971 100644 --- a/src/shared/tui-agent-startup-session-options.test.ts +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -4,6 +4,7 @@ import { buildAgentResumeStartupPlan, buildAgentStartupPlan } from './tui-agent-startup' +import { resolveAgentLaunchCommand } from './tui-agent-launch-command' describe('tui agent startup session options', () => { it('emits catalog options before user arguments without recording an overridden model', () => { @@ -33,6 +34,59 @@ describe('tui agent startup session options', () => { expect(plan?.sessionOptions).toEqual({ model: 'opus' }) }) + it('lets explicit worker preferences override general agent arguments', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'custom-codex-model', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true, + agentArgs: '-m gpt-5.5 -c model_reasoning_effort=low' + }) + expect(plan?.launchCommand).toBe( + "codex '-m' 'custom-codex-model' '-c' 'model_reasoning_effort=high'" + ) + expect(plan?.launchConfig.agentCommand).toBe( + "codex '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=low'" + ) + expect(plan?.sessionOptions).toEqual({ model: 'custom-codex-model', effort: 'high' }) + }) + + it('inserts worker preferences before an argument terminator', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'custom-codex-model', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true, + agentArgs: '--dangerously-bypass-approvals-and-sandbox -- literal' + }) + expect(plan?.launchCommand).toBe( + "codex '--dangerously-bypass-approvals-and-sandbox' '-m' 'custom-codex-model' '-c' 'model_reasoning_effort=high' '--' 'literal'" + ) + }) + + it('rejects conflicting singleton flags in an agent command override', () => { + expect( + resolveAgentLaunchCommand({ + agent: 'codex', + cmdOverrides: { codex: 'codex --profile work -m gpt-5.5' }, + platform: 'linux', + shell: 'posix', + sessionOptions: { model: 'custom-codex-model', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true + }) + ).toEqual({ + ok: false, + error: + 'Agent command override conflicts with the requested launch preferences. Remove model or effort flags from the command override.' + }) + }) + it('recognizes a long Codex model flag overriding the generated short flag', () => { const plan = buildAgentStartupPlan({ agent: 'codex', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 682643f7c79..27d4b40ac8d 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -36,9 +36,7 @@ export type AgentStartupPlan = { sessionOptions?: Record } -function appliedSessionOptionProps( - values: Record -): Pick { +function appliedSessionOptionProps(values: Record) { return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} } @@ -52,6 +50,7 @@ export function buildAgentStartupPlan(args: { agentArgs?: string | null agentEnv?: Record | null sessionOptions?: Record + sessionOptionsOverrideAgentArgs?: boolean /** Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only * `orca-ide` rename must be skipped for remote launches. */ isRemote?: boolean @@ -68,6 +67,7 @@ export function buildAgentStartupPlan(args: { shell, agentArgs: usesQuery ? null : args.agentArgs, sessionOptions: args.sessionOptions, + sessionOptionsOverrideAgentArgs: args.sessionOptionsOverrideAgentArgs, isRemote: args.isRemote }) if (!baseCommand.ok) { From 3d6a98a834319f5ef6df0761de0b9e42d7530f51 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:54:45 -0700 Subject: [PATCH 29/46] fix(native-chat): report the current Claude model from the terminal frame (#12860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): locate Claude's model row by frame structure The scraper assumed the model descriptor sits within three rows of the `Claude Code vX` line. It does not: Claude prints it near the bottom of the startup frame with the welcome art and release-notes panel in between — eight rows down at 100 columns. Narrow panes degrade the frame further, dropping the version from the title row entirely below ~70 columns and wrapping the billing tail onto its own row. Any one of those made the scrape return null, so the model picker showed no current selection at all. Search the frame from its bottom border upward for the row carrying model metadata, read only the leftmost frame cell so release-notes prose can never win, accept the frame corner as header proof when the version is gone, and tolerate the effort suffix being elided to an ellipsis. Catalog families now match as a leading word, which both survives the resolved-name suffix ("Opus 5 (1M context)") and keeps custom slugs like company/my-haiku-v2 from being claimed as haiku; an unrecognized name is reported as a custom model. Fixtures are real: captured from a live claude 2.1.220 by replaying the PTY bytes through @xterm/headless and serializing exactly as TerminalPane does. * fix(native-chat): resolve the scraped model against the host's real catalog The scraper matched the static seed while the picker lists what #12369 discovers from the host CLI, so the two spoke different id spaces. On a current CLI `list_models` returns `opus[1m]`, `sonnet`, `sonnet[1m]`, `fable` and `haiku` — no plain `opus` — while the seed only knows families. Reporting `opus` therefore selected a row the picker had to invent, dropping the host's own effort and fast-mode descriptors with it. Locating the model row correctly made this the normal case rather than a rarity, since the scrape now succeeds. Resolve against the discovered list first, falling back to the seed for aliases a host no longer lists and to the raw name for genuinely custom models. Matching requires the family to lead as a whole word and the label's remaining tokens to appear in order, so `Opus 5 (1M context)` picks `opus[1m]`, plain `Sonnet 5` keeps `sonnet` instead of being captured by the 1M-context row, and `opus-internal-v3` stays custom. Most specific label wins. The hook keeps the screen that parsed so a discovery landing after the first read re-resolves it, rather than stranding a family id once the frame has scrolled out of the buffer. * fix(native-chat): identify option-less models on narrow panes Live capture at 60 columns: a Haiku session prints a bare `Haiku 4.5` row with its billing wrapped to the next line. No middot, no effort suffix — nothing marks it as the model, so it reported nothing. Claude always closes the frame with the working directory and prints at most the descriptor plus a wrapped billing line above it, so fall back to walking up from there when no row carries descriptor metadata. The height bound is what keeps the walk from climbing into the welcome art. * test(native-chat): pin re-resolution when discovery lands after the read Covers the wiring the parser tests cannot reach: the frame is visible at mount and gone by the time the host's model list arrives, so only the cached screen can drive the second resolve. Fails against a listener that merely replaces the models. * fix(native-chat): prevent stale Claude model reports --- .../claude-terminal-session-options.test.ts | 326 ++++++++++++++++++ .../claude-terminal-session-options.ts | 217 +++++++++++- .../use-native-chat-session-options.test.ts | 178 ++++++++++ .../use-native-chat-session-options.ts | 59 +++- 4 files changed, 748 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts index a41d62eeb7a..82f9280bffb 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts @@ -1,7 +1,277 @@ import { describe, expect, it } from 'vitest' import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' +// Captured from a live `claude` 2.1.220 startup frame by replaying the PTY +// bytes through @xterm/headless and calling serializeAddon.serialize({ +// scrollback: 0 }) — byte-for-byte what TerminalPane hands the scraper. The two +// widths matter: at 100 columns the frame keeps its version and prints billing +// on the model row, at 60 it drops the version and wraps billing to its own row. + +const FRESH_SESSION_100_COLS = [ + '\u001b[?1049h\u001b[H\r', + '\u001b[38;2;215;119;87m╭───\u001b[1CClaude Code\u001b[1C\u001b[38;2;153;153;153mv2.1.220\u001b[1C\u001b[38;2;215;119;87m─────────────────────────────────────────────────────────────────────────╮\r', + '│\u001b[50C\u001b[2m│\u001b[1C\u001b[22;1mTips for getting started\u001b[22C\u001b[22m│\r', + '│\u001b[19C\u001b[39;1mWelcome back!\u001b[18C\u001b[38;2;215;119;87;22;2m│\u001b[1C\u001b[0mRun\u001b[1C/init\u001b[1Cto\u001b[1Ccreate\u001b[1Ca\u001b[1CCLAUDE.md\u001b[1Cfile\u001b[1Cwith\u001b[1Cin…\u001b[1C\u001b[38;2;215;119;87m│\r', + '│\u001b[50C\u001b[2m│\u001b[1C\u001b[22m─────────────────────────────────────────────\u001b[1C│\r', + "│\u001b[21C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[21C\u001b[2m│\u001b[1C\u001b[22;1mWhat's new\u001b[36C\u001b[22m│\r", + '│\u001b[21C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[20C\u001b[2m│\u001b[1C\u001b[0mBug\u001b[1Cfixes\u001b[1Cand\u001b[1Creliability\u001b[1Cimprovements\u001b[8C\u001b[38;2;215;119;87m│\r', + '│\u001b[21C ▘▘ ▝▝ \u001b[20C\u001b[2m│\u001b[1C\u001b[0mAdded\u001b[1CClaude\u001b[1COpus\u001b[1C5\u001b[1C(`claude-opus-5`),\u001b[1Cnow\u001b[1Ct…\u001b[1C\u001b[38;2;215;119;87m│\r', + '│\u001b[50C\u001b[2m│\u001b[1C\u001b[0mAdded\u001b[1C`sandbox.network.strictAllowlist`\u001b[1Csett…\u001b[1C\u001b[38;2;215;119;87m│\r', + '│\u001b[3C\u001b[38;2;153;153;153mFable 5 with high effort · API Usage Billing\u001b[3C\u001b[38;2;215;119;87;2m│\u001b[1C\u001b[38;2;153;153;153;22;3m/release-notes for more\u001b[23C\u001b[38;2;215;119;87;23m│\r', + '│\u001b[19C\u001b[38;2;153;153;153m/private/tmp\u001b[19C\u001b[38;2;215;119;87;2m│\u001b[47C\u001b[22m│\r', + '╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source is set and …\r', + '\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────\r', + '\u001b[0m❯ \u001b[2mTry "fix typecheck errors"\r', + '\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────────────────────────────────────────────\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker\u001b[38;2;153;153;153m · restart with CLAUDE_COD…\r', + '\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mFable 5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r', + '\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h' +].join('\n') + +const FRESH_SESSION_60_COLS = [ + '\u001b[?1049h\u001b[H\r', + '\u001b[38;2;215;119;87m╭─ Claude Code ────────────────────────────────────────────╮\r', + '│\u001b[58C│\r', + '│\u001b[22C\u001b[39;1mWelcome back!\u001b[23C\u001b[38;2;215;119;87;22m│\r', + '│\u001b[58C│\r', + '│\u001b[25C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[25C│\r', + '│\u001b[25C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[24C│\r', + '│\u001b[25C ▘▘ ▝▝ \u001b[24C│\r', + '│\u001b[58C│\r', + '│\u001b[17C\u001b[38;2;153;153;153mFable 5 with high effort\u001b[17C\u001b[38;2;215;119;87m│\r', + '│\u001b[20C\u001b[38;2;153;153;153mAPI Usage Billing\u001b[21C\u001b[38;2;215;119;87m│\r', + '│\u001b[23C\u001b[38;2;153;153;153m/private/tmp\u001b[23C\u001b[38;2;215;119;87m│\r', + '│\u001b[58C│\r', + '╰──────────────────────────────────────────────────────────╯\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_AP…\r', + '\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────\r', + '\u001b[0m❯ \u001b[2mTry "refactor "\r', + '\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_…\r', + '\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mFable 5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r', + '\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h' +].join('\n') + +const OPTION_LESS_MODEL_60_COLS = [ + '\u001b[?1049h\u001b[H\r', + '\u001b[38;2;215;119;87m╭─ Claude Code ────────────────────────────────────────────╮\r', + '│\u001b[58C│\r', + '│\u001b[22C\u001b[39;1mWelcome back!\u001b[23C\u001b[38;2;215;119;87;22m│\r', + '│\u001b[58C│\r', + '│\u001b[25C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[25C│\r', + '│\u001b[25C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[24C│\r', + '│\u001b[25C ▘▘ ▝▝ \u001b[24C│\r', + '│\u001b[58C│\r', + '│\u001b[24C\u001b[38;2;153;153;153mHaiku 4.5\u001b[25C\u001b[38;2;215;119;87m│\r', + '│\u001b[20C\u001b[38;2;153;153;153mAPI Usage Billing\u001b[21C\u001b[38;2;215;119;87m│\r', + '│\u001b[23C\u001b[38;2;153;153;153m/private/tmp\u001b[23C\u001b[38;2;215;119;87m│\r', + '│\u001b[58C│\r', + '╰──────────────────────────────────────────────────────────╯\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_AP…\r', + '\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────\r', + '\u001b[0m❯ \u001b[2mTry "how does work?"\r', + '\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────\r', + '\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_…\r', + '\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mHaiku 4.5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r', + '\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h' +].join('\n') + +const EFFORT = { + id: 'effort', + label: 'Effort', + kind: { + type: 'select' as const, + choices: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ], + defaultValue: 'high' + }, + apply: {} +} + describe('Claude terminal session option detection', () => { + it('reads the model from a real 100-column startup frame', () => { + // The descriptor sits eight rows under the version, below the welcome art. + expect(readClaudeSessionOptionsFromTerminalScreen(FRESH_SESSION_100_COLS)).toEqual({ + model: 'fable', + effort: 'high' + }) + }) + + it('reads a real 60-column frame that drops the version and wraps billing', () => { + expect(readClaudeSessionOptionsFromTerminalScreen(FRESH_SESSION_60_COLS)).toEqual({ + model: 'fable', + effort: 'high' + }) + }) + + it('reads a real narrow frame whose option-less model carries no metadata', () => { + // Why: `Haiku 4.5` has no effort control and its billing wrapped to the next + // row, so nothing marks the row as a model — only its position above the + // working directory does. + expect(readClaudeSessionOptionsFromTerminalScreen(OPTION_LESS_MODEL_60_COLS)).toEqual({ + model: 'haiku' + }) + }) + + it('reads an option-less model above a Windows working directory', () => { + const screen = [ + '╭─ Claude Code ───╮', + '│Haiku 4.5│', + '│API Usage Billing│', + '│C:\\work\\repo│', + '╰─────────────╯' + ].join('\r\n') + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ model: 'haiku' }) + }) + + it('reports nothing rather than release-notes prose when the model row is bare', () => { + // Why: a custom model with its billing wrapped away carries no descriptor + // metadata, so nothing in the frame identifies it. The panel shares rows + // with the art and its borders collide (`││`) — reading past them would + // make panel prose win the search and report a sentence as the model. + const screen = [ + '╭─── Claude Codev2.1.220 ───────────────────────╮', + '││Runs faster · uses fewer tokens│', + '│ ▐▛███▜▌│What‽s new│', + '│acme-frontier│', + '│API Usage Billing│', + '│/repo││', + '╰───────────────────────────────────────────────╯' + ].join('\r\n') + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toBeNull() + }) + + it('reports nothing when a partial frame only contains wrapped billing', () => { + const screen = [ + '╭─ Claude Code ───╮', + '│API Usage Billing│', + '│/repo│', + '╰─────────────╯' + ].join('\r\n') + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toBeNull() + }) + + it('does not reject a bare custom model whose name mentions billing', () => { + const screen = ['╭─ Claude Code ───╮', '│billing-model│', '│/repo│', '╰─────────────╯'].join( + '\r\n' + ) + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'billing-model' + }) + }) + + // The exact catalog `claude -p` list_models returns on CLI 2.1.220: note the + // host offers no plain `opus`, and `sonnet` and `sonnet[1m]` are distinct. + const DISCOVERED = [ + { id: 'opus[1m]', label: 'Opus (1M context)', options: [EFFORT] }, + { id: 'sonnet', label: 'Sonnet', options: [EFFORT] }, + { id: 'sonnet[1m]', label: 'Sonnet 5 (1M context)', options: [EFFORT] }, + { id: 'fable', label: 'Fable', options: [EFFORT] }, + { id: 'haiku', label: 'Haiku', options: [] } + ] + + function frame(descriptor: string): string { + return `Claude Code v2.1.220\r\n${descriptor}\r\n~/repo` + } + + it('resolves to the host id the picker lists, not the seed family', () => { + // Why: `opus` is not offered on this host, so reporting it would select a + // row the picker has to invent. + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Opus 5 (1M context) with high effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'opus[1m]', effort: 'high' }) + }) + + it('prefers the most specific variant when a family also matches', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Sonnet 5 (1M context) with low effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'sonnet[1m]', effort: 'low' }) + }) + + it('keeps the plain family when the frame names no variant', () => { + // Why: `Sonnet 5` must not be captured by the 1M-context row just because + // that row's label happens to start with the same two tokens. + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Sonnet 5 with medium effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'sonnet', effort: 'medium' }) + }) + + it('falls back to a seed family the host list no longer offers', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('Opus 4.8 with high effort · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'opus', effort: 'high' }) + }) + + it('still reports a custom model when the host list cannot name it', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + frame('company/my-haiku-v2 · API Usage Billing'), + DISCOVERED + ) + ).toEqual({ model: 'company/my-haiku-v2' }) + }) + + it('reads an effort suffix the frame truncated to an ellipsis', () => { + const screen = + 'Claude Code v2.1.220\r\n' + + 'Opus 5 (1M context) with high… · API Usage Billing\r\n' + + '~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus', + effort: 'high' + }) + }) + it('reads the current model and effort from Claude header chrome', () => { const screen = '\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' + @@ -63,4 +333,60 @@ describe('Claude terminal session option detection', () => { readClaudeSessionOptionsFromTerminalScreen('I recommend Opus 4.8 for this task.') ).toBeNull() }) + + it('recovers a custom model name that is not in the catalog', () => { + const screen = + '\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' + + '\u001b[38;2;102;102;102mmy-custom-model with high effort · API Usage Billing\r\n' + + '~/repo' + + // Custom models have no catalog options, so effort is intentionally dropped. + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'my-custom-model' + }) + }) + + it('recovers a custom model with no effort suffix', () => { + const screen = 'Claude Code v2.1.211\r\ncompany/internal-opus · API Usage Billing\r\n~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'company/internal-opus' + }) + }) + + it('keeps a custom model containing a catalog label', () => { + const screen = 'Claude Code v2.1.211\r\ncompany/my-haiku-v2 · API Usage Billing\r\n~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'company/my-haiku-v2' + }) + }) + + it('keeps a custom model whose name only starts with a catalog family', () => { + // Why: the family must match as a whole leading word, not a bare prefix, or + // every vendor slug built off a family name would be claimed as that model. + const screen = 'Claude Code v2.1.211\r\nopus-internal-v3 · API Usage Billing\r\n~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus-internal-v3' + }) + }) + + it('ignores catalog labels outside the descriptor row', () => { + const screen = 'Claude Code v2.1.211\r\nacme-frontier · API Usage Billing\r\n~/work/Haiku' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'acme-frontier' + }) + }) + + it('recovers a custom model when xterm joins the descriptor to the header', () => { + const screen = + '\u001b[?1049h\u001b[H▐▛███▜▌Claude Codev2.1.211' + + '▝▜█████▛▘acme-frontier with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'acme-frontier' + }) + }) }) diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts index 7a1e5b8fbd8..3b8ad86a47a 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts @@ -1,4 +1,7 @@ -import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import { + getAgentSessionOptionCatalog, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import { stripScrollbackAnsi } from './native-chat-scrape-fallback' @@ -11,41 +14,219 @@ const EFFORT_ID_BY_LABEL: Record = { max: 'max' } +// Why: narrow panes elide the trailing `effort` word to an ellipsis, so the +// level itself is the only part of the suffix we can rely on. +const CLAUDE_MODEL_EFFORT = + /\bwith\s+(extra high|xhigh|medium|high|low|max)(?:\s+effort\b|\s*…|\s*$)/i + +// Claude's startup frame. Chat text uses an em dash rather than these box +// characters, so a leading corner is safe proof that a row is chrome. +const FRAME_TOP = '╭' +const FRAME_BOTTOM = '╰' +const FRAME_COLUMN = '│' + +/** Rows of the visible screen, ANSI-stripped and whitespace-collapsed. */ function normalizedScreenLines(screen: string): string[] { return stripScrollbackAnsi(screen) .split('\n') .map((line) => line.replace(/\s+/g, ' ').trim()) } +function isClaudeHeaderRow(line: string): boolean { + // No trailing word boundary: serialization drops the gap before the version, + // leaving `Claude Codev2.1.220`. + if (!/\bClaude Code/i.test(line)) { + return false + } + // Why: panes under ~70 columns drop the version from the frame entirely, so + // accept the frame corner as the alternative proof this is Claude's chrome. + // xterm serialization also drops cursor-positioning cells between the product + // name and version, producing `Claude Codev2.1.211`. + return /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) || line.startsWith(FRAME_TOP) +} + +/** The frame's leftmost cell on a row. Reading up to the next column border + * keeps the release-notes panel out: on rows whose left cell is empty the + * borders collide (`││`) and this yields nothing rather than the panel text. */ +function frameCell(line: string): string { + if (line.startsWith(FRAME_COLUMN)) { + const afterBorder = line.slice(FRAME_COLUMN.length) + const columnEnd = afterBorder.indexOf(FRAME_COLUMN) + return (columnEnd < 0 ? afterBorder : afterBorder.slice(0, columnEnd)).trim() + } + // Unframed buffers put the descriptor on the row directly, behind box art. + return line.replace(/^[^A-Za-z0-9]+/, '').trim() +} + +/** Whether a cell carries the metadata Claude only prints on the model row, so + * ordinary frame text is never mistaken for a model. */ +function isModelDescriptorCell(cell: string): boolean { + return cell.includes('·') || CLAUDE_MODEL_EFFORT.test(cell) +} + +function isWorkingDirectoryCell(cell: string): boolean { + return ( + cell.startsWith('/') || + cell.startsWith('~') || + /^[A-Za-z]:[\\/]/.test(cell) || + /^\\\\[^\\]/.test(cell) + ) +} + +/** + * Claude prints the model descriptor near the bottom of its startup frame, with + * the welcome art and the release-notes panel in between — it is not adjacent to + * the version row. Search the frame from the bottom up so the panel's own text + * can never win over the descriptor. + */ +function claudeModelDescriptorCell(lines: string[], headerIndex: number): string | null { + const frameBottom = lines.findIndex( + (line, index) => index > headerIndex && line.startsWith(FRAME_BOTTOM) + ) + // Why: an unframed buffer gives no bottom to bound the search, so keep the + // original tight window rather than reaching into conversation text. + const lastRow = frameBottom > 0 ? frameBottom - 1 : Math.min(headerIndex + 2, lines.length - 1) + for (let index = lastRow; index > headerIndex; index -= 1) { + const cell = frameCell(lines[index] ?? '') + if (isModelDescriptorCell(cell)) { + return cell + } + } + // xterm serialization can join the descriptor onto the version row itself. + const joined = frameCell((lines[headerIndex] ?? '').replace(/^.*?\bClaude Code\s*v?[\d.]*/i, '')) + if (isModelDescriptorCell(joined)) { + return joined + } + return frameBottom > 0 ? modelRowAboveWorkingDirectory(lines, headerIndex, frameBottom) : null +} + +/** + * A model with no effort control whose billing wrapped away — `Haiku 4.5` on a + * narrow pane — leaves a bare name that no metadata identifies. Claude always + * closes the frame with the working directory, and prints at most the + * descriptor plus a wrapped billing line above it, so walk up from there. + */ +function modelRowAboveWorkingDirectory( + lines: string[], + headerIndex: number, + frameBottom: number +): string | null { + let workingDirectory = -1 + for (let index = frameBottom - 1; index > headerIndex; index -= 1) { + const cell = frameCell(lines[index] ?? '') + if (isWorkingDirectoryCell(cell)) { + workingDirectory = index + break + } + } + if (workingDirectory < 0) { + return null + } + let top = workingDirectory + while (top - 1 > headerIndex && frameCell(lines[top - 1] ?? '')) { + top -= 1 + } + // Anything taller than descriptor + billing is the welcome art, not a model. + if (top >= workingDirectory || workingDirectory - top > 2) { + return null + } + const cell = frameCell(lines[top] ?? '') + return /^API Usage Billing$/i.test(cell) ? null : cell +} + +/** The displayed model name, without the effort suffix or billing tail. */ +function parseClaudeModelName(cell: string): string | null { + // split always yields a non-empty first segment for a non-empty cell. + const beforeBilling = cell.split('·')[0]! + const effort = beforeBilling.match(CLAUDE_MODEL_EFFORT) + const name = ( + effort?.index === undefined ? beforeBilling : beforeBilling.slice(0, effort.index) + ).trim() + return name || null +} + +function modelNameTokens(value: string): string[] { + return value.toLowerCase().match(/[a-z]+|\d+[a-z]*/g) ?? [] +} + +/** + * Whether a catalog label names the model the frame reported. The frame prints + * the resolved model ("Opus 5 (1M context)") while labels name the alias + * ("Opus (1M context)"), so the family has to lead as a whole word and the + * label's remaining tokens have to appear in order. Requiring a whole word is + * what keeps `company/my-haiku-v2` and `opus-internal-v3` out of the catalog; + * requiring the rest is what stops `Sonnet` from claiming the 1M-context row. + */ +function labelNamesReportedModel(reportedModel: string, label: string): boolean { + const labelTokens = modelNameTokens(label) + const family = labelTokens[0] + if (!family) { + return false + } + const name = reportedModel.toLowerCase() + if (name !== family && !name.startsWith(`${family} `)) { + return false + } + const nameTokens = modelNameTokens(name) + let cursor = 1 + for (const token of labelTokens.slice(1)) { + const found = nameTokens.indexOf(token, cursor) + if (found < 0) { + return false + } + cursor = found + 1 + } + return true +} + +/** The most specific catalog entry naming this model, preferring the host's + * discovered list so variants like `opus[1m]` win over the seed family. */ +function findClaudeCatalogModel( + reportedModel: string, + models: readonly CatalogModel[] | undefined +): CatalogModel | undefined { + const seeded = getAgentSessionOptionCatalog('claude')?.models ?? [] + for (const candidates of [models ?? [], seeded]) { + const matches = candidates.filter(({ label }) => labelNamesReportedModel(reportedModel, label)) + const best = matches.sort( + (left, right) => modelNameTokens(right.label).length - modelNameTokens(left.label).length + )[0] + if (best) { + return best + } + } + return undefined +} + +/** + * @param models the host's live model list. Pass the discovered catalog so the + * reported id is one the picker actually lists; omitting it falls back to the + * version-neutral seed families. + */ export function readClaudeSessionOptionsFromTerminalScreen( - screen: string | null | undefined + screen: string | null | undefined, + models?: readonly CatalogModel[] ): Record | null { if (!screen) { return null } const lines = normalizedScreenLines(screen) - const headerIndex = lines.findIndex((line) => - // xterm serialization can remove cursor-positioning cells between the - // product name and version, producing `Claude Codev2.1.211`. - /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) - ) + const headerIndex = lines.findIndex(isClaudeHeaderRow) if (headerIndex < 0) { return null } - // Why: only Claude's fixed header rows describe live state; conversation - // text and old command confirmations elsewhere in the buffer can be stale. - const header = lines.slice(headerIndex, headerIndex + 3).join(' ') - const catalog = getAgentSessionOptionCatalog('claude') - const model = [...(catalog?.models ?? [])] - .sort((left, right) => right.label.length - left.label.length) - .find(({ label }) => header.toLowerCase().includes(label.toLowerCase())) - if (!model) { + const descriptorCell = claudeModelDescriptorCell(lines, headerIndex) + const reportedModel = descriptorCell ? parseClaudeModelName(descriptorCell) : null + if (!reportedModel) { return null } + const model = findClaudeCatalogModel(reportedModel, models) + if (!model) { + // A custom model carries no catalog options, so no effort is reported. + return { model: reportedModel } + } const result: Record = { model: model.id } - const effortLabel = header.match( - /\bwith\s+(low|medium|high|extra high|xhigh|max)\s+effort\b/i - )?.[1] + const effortLabel = descriptorCell?.match(CLAUDE_MODEL_EFFORT)?.[1] const effort = effortLabel ? EFFORT_ID_BY_LABEL[effortLabel.toLowerCase()] : undefined if (effort && model.options.some((option) => option.id === 'effort')) { result.effort = effort diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts new file mode 100644 index 00000000000..a24d1b8f87f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.test.ts @@ -0,0 +1,178 @@ +// @vitest-environment happy-dom + +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment' + +const discoverModels = vi.fn<() => Promise>() + +vi.mock('./native-chat-session-option-discovery', () => ({ + resolveNativeChatModelDiscoveryContext: () => ({ hostKey: 'host', runtime: null }), + discoverNativeChatCatalogModels: () => discoverModels() +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign(() => undefined, { + getState: () => ({ settings: {}, updateSettings: async () => undefined }) + }) +})) + +import { useNativeChatSessionOptions } from './use-native-chat-session-options' + +// A 1M-context Opus session: the frame names the resolved model while the +// host's discovered catalog names the alias. +const CLAUDE_SCREEN = + 'Claude Code v2.1.220\r\nOpus 5 (1M context) with high effort · API Usage Billing\r\n~/repo' + +const DISCOVERED: CatalogModel[] = [ + { id: 'opus[1m]', label: 'Opus (1M context)', options: [] }, + { id: 'haiku', label: 'Haiku', options: [] } +] + +function modelDescriptor(snapshot: { id: string; kind: unknown }[]): { + currentValue?: string + choices: { value: string }[] +} { + const model = snapshot.find((descriptor) => descriptor.id === 'model') + return model?.kind as { currentValue?: string; choices: { value: string }[] } +} + +describe('useNativeChatSessionOptions model reporting', () => { + beforeEach(() => { + clearNativeChatModelEnrichmentForTests() + discoverModels.mockReset() + Object.defineProperty(window, 'api', { configurable: true, value: undefined }) + }) + + it('re-resolves the reported model against models discovered after the read', async () => { + // Why: discovery is async, so the first scrape can only reach the seed. If + // the reported id were left at the family the picker would show a row it + // had to invent — and by then the frame may have scrolled out of the buffer, + // so re-reading the screen is not an option. + let resolveDiscovery: (models: CatalogModel[]) => void = () => {} + discoverModels.mockReturnValue( + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + + // Stable identity, and the frame scrolls out of the buffer before discovery + // lands — so nothing but the cached screen can drive the re-resolution. + let frameVisible = true + const readTerminalScreen = (): string | null => + frameVisible ? CLAUDE_SCREEN : 'conversation has scrolled past the frame' + const dispatchCommand = vi.fn() + + const { result } = renderHook(() => + useNativeChatSessionOptions({ + agent: 'claude', + terminalTabId: 'tab-1', + targetPtyId: 'pty-1', + dispatchCommand, + readTerminalScreen + }) + ) + + await waitFor(() => expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus')) + + frameVisible = false + resolveDiscovery(DISCOVERED) + + await waitFor(() => + expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus[1m]') + ) + // The invented family row is gone: every choice is one the host listed. + expect(modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value)).toEqual([ + 'opus[1m]', + 'haiku' + ]) + }) + + it('does not re-resolve a late snapshot from the previous pty', async () => { + let resolveDiscovery: (models: CatalogModel[]) => void = () => {} + discoverModels.mockReturnValue( + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + let resolveOldSnapshot: (snapshot: { data: string; alternateScreen: false }) => void = () => {} + const oldSnapshot = new Promise<{ data: string; alternateScreen: false }>((resolve) => { + resolveOldSnapshot = resolve + }) + const getMainBufferSnapshot = vi + .fn() + .mockReturnValueOnce(oldSnapshot) + .mockReturnValueOnce(new Promise(() => {})) + Object.defineProperty(window, 'api', { + configurable: true, + value: { pty: { getMainBufferSnapshot } } + }) + + const dispatchCommand = vi.fn() + const { result, rerender } = renderHook( + ({ targetPtyId }) => + useNativeChatSessionOptions({ + agent: 'claude', + terminalTabId: 'tab-1', + targetPtyId, + dispatchCommand, + readTerminalScreen: () => null + }), + { initialProps: { targetPtyId: 'pty-old' } } + ) + + rerender({ targetPtyId: 'pty-new' }) + resolveOldSnapshot({ data: CLAUDE_SCREEN, alternateScreen: false }) + await Promise.resolve() + resolveDiscovery(DISCOVERED) + + await waitFor(() => + expect( + modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value) + ).toEqual(['opus[1m]', 'haiku']) + ) + expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined() + }) + + it('clears a previously reported screen when the target pty changes', async () => { + let resolveDiscovery: (models: CatalogModel[]) => void = () => {} + discoverModels.mockReturnValue( + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + const getMainBufferSnapshot = vi + .fn() + .mockResolvedValueOnce({ data: CLAUDE_SCREEN, alternateScreen: false }) + .mockReturnValueOnce(new Promise(() => {})) + Object.defineProperty(window, 'api', { + configurable: true, + value: { pty: { getMainBufferSnapshot } } + }) + + const dispatchCommand = vi.fn() + const { result, rerender } = renderHook( + ({ targetPtyId }) => + useNativeChatSessionOptions({ + agent: 'claude', + terminalTabId: 'tab-reset', + targetPtyId, + dispatchCommand, + readTerminalScreen: () => null + }), + { initialProps: { targetPtyId: 'pty-reported' } } + ) + await waitFor(() => expect(modelDescriptor(result.current.snapshot).currentValue).toBe('opus')) + + rerender({ targetPtyId: 'pty-empty' }) + resolveDiscovery(DISCOVERED) + + await waitFor(() => + expect( + modelDescriptor(result.current.snapshot).choices.map((choice) => choice.value) + ).toEqual(['opus[1m]', 'haiku']) + ) + expect(modelDescriptor(result.current.snapshot).currentValue).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts index b0dfe6503fe..a722a1ffc98 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useSyncExternalStore } from 'react' +import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import type { AgentType } from '../../../../shared/agent-status-types' import { updateNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' @@ -36,6 +36,9 @@ export function useNativeChatSessionOptions(args: { } { const { agent, terminalTabId, targetPtyId, dispatchCommand, onAgentPicker, readTerminalScreen } = args + // The screen text that last parsed into reported values, so a later model + // discovery can re-resolve it against the host's real ids. + const reportedScreenRef = useRef(null) const discoveryContext = useMemo( () => resolveNativeChatModelDiscoveryContext(terminalTabId), [terminalTabId] @@ -47,8 +50,16 @@ export function useNativeChatSessionOptions(args: { return null } const scopeKey = targetPtyId ?? terminalTabId + const discoveredModels = discoveryContext + ? readNativeChatEnrichedModels(agent, discoveryContext.hostKey) + : null const reportedValues = - agent === 'claude' ? readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) : null + agent === 'claude' + ? readClaudeSessionOptionsFromTerminalScreen( + readTerminalScreen?.(), + discoveredModels ?? undefined + ) + : null let settingsWrite = Promise.resolve() return createNativeChatPtySessionOptions({ agent, @@ -57,12 +68,7 @@ export function useNativeChatSessionOptions(args: { // Why: the catalog seed carries version-neutral family labels, so it is // safe on every host while the once-per-host probe runs or after it fails // — without it the whole picker would pop in late or never appear. - ...(discoveryContext - ? { - initialModels: - readNativeChatEnrichedModels(agent, discoveryContext.hostKey) ?? undefined - } - : {}), + ...(discoveryContext ? { initialModels: discoveredModels ?? undefined } : {}), mode: targetPtyId ? 'live' : 'draft', reportedValues, dispatchCommand, @@ -105,6 +111,7 @@ export function useNativeChatSessionOptions(args: { return } let cancelled = false + reportedScreenRef.current = null const reportCurrentValues = async (): Promise => { let authoritativeScreen: string | null = null if (targetPtyId && window.api?.pty?.getMainBufferSnapshot) { @@ -119,18 +126,33 @@ export function useNativeChatSessionOptions(args: { // The mounted renderer buffer remains a transport-neutral fallback. } } - const reportedValues = - readClaudeSessionOptionsFromTerminalScreen(authoritativeScreen) ?? - readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) - if (!cancelled && reportedValues) { + const models = discoveryContext + ? readNativeChatEnrichedModels(agent, discoveryContext.hostKey) + : null + for (const screen of [authoritativeScreen, readTerminalScreen?.() ?? null]) { + const reportedValues = readClaudeSessionOptionsFromTerminalScreen( + screen, + models ?? undefined + ) + if (!reportedValues) { + continue + } + // Why: discovery can land after this read. Keeping the screen that + // parsed lets it re-resolve against the host's real ids later, when the + // frame itself may have already scrolled out of the buffer. + if (cancelled) { + return + } + reportedScreenRef.current = screen surface.reportSessionOptions(reportedValues) + return } } void reportCurrentValues() return () => { cancelled = true } - }, [agent, readTerminalScreen, surface, targetPtyId]) + }, [agent, discoveryContext, readTerminalScreen, surface, targetPtyId]) useEffect(() => { if (!surface || !discoveryContext) { @@ -139,7 +161,16 @@ export function useNativeChatSessionOptions(args: { const unsubscribe = subscribeNativeChatEnrichedModels( agent, discoveryContext.hostKey, - (models) => surface.replaceModels(models) + (models) => { + surface.replaceModels(models) + const screen = agent === 'claude' ? reportedScreenRef.current : null + const reportedValues = screen + ? readClaudeSessionOptionsFromTerminalScreen(screen, models) + : null + if (reportedValues) { + surface.reportSessionOptions(reportedValues) + } + } ) ensureNativeChatModelEnrichment({ agent, From cd8c66551af780d01a74a8f73a6f089dd3d844a4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:06:36 -0700 Subject: [PATCH 30/46] fix(agent-hooks): resumed Claude Code session gets its sidebar agent row at SessionStart (STA-3386) (#12859) * fix(agent-hooks): give resumed Claude sessions a sidebar row at SessionStart (STA-3386) Claude's hook set never registered SessionStart and normalizeClaudeEvent dropped it at ingest, so a resumed session that idled produced zero hook traffic and earned no sidebar agent row until the first prompt. - Register SessionStart in CLAUDE_EVENTS (local + remote installs). - Map lead SessionStart (startup/resume/clear) to an idle 'done' row, resetting stale roster/task/cron/tool/prompt state like the Codex path; compact restarts and child-attributed SessionStart stay dropped. - Thread hookEventName through the agent-status IPC payload so the completion coordinator can tell a session connect from a turn result; a SessionStart 'done' no longer raises agent-task-complete. * fix(agent-hooks): mark SessionStart rows as session boundaries, not completions (STA-3386) Review follow-up: represent the idle connect as a first-class sessionBoundary flag on the status payload instead of gating one renderer consumer on hookEventName. - sessionBoundary rides AgentStatusPayload/AgentStatusEntry (done-only, clamped like interrupted); drops the hookEventName IPC threading. - Completion-reactive consumers ignore session boundaries: the completion coordinator (task-complete notifications), automation dispatch observers (a connecting agent no longer completes the run and closes its tab), activity unread counts, and the dashboard finished timestamp; the status slice keeps boundaries out of stateHistory and preserves the flag across done->done repaints. - SessionStart sources are allowlisted (startup/resume/clear) so compact restarts or unknown sources fail closed mid-turn. - A live SessionStart now un-retires a reusable pane like a fresh prompt, so resume-in-reused-pane earns its row too. * fix(agent-hooks): keep session-boundary dones out of teardown and completion history (STA-3386) Review round 2: - A boundary done no longer deletes the pane's launch-config registry entry, so a resumed idle TUI keeps its registered-launch-agent identity evidence. - A boundary landing on a REAL done pushes that completion into stateHistory so the finished timestamp and unread badge survive a resume//clear right after a finish. - The done->done flag carry yields to turn evidence (assistant message or changed prompt) so a genuine completion can never be suppressed. - Star-nag value-moment observer and the server's OSC-equivalence dedupe now discriminate the flag. * fix(agent-hooks): keep a displaced completion unread in the sidebar badge (STA-3386) Review round 3: sidebar-badge mode counts only the live entry, so a session boundary landing on an unacknowledged completion silently dropped the sidebar badge while the agent-events count kept it. Count the displaced completion from history for boundary rows, and pin the behavior with countActivityUnread tests. * fix(agent-hooks): prevent SessionStart completion side effects (STA-3386) * fix(agent-hooks): preserve SessionStart through renderer IPC (STA-3386) --- src/cli/runtime/websocket-transport.test.ts | 6 +- src/main/agent-hooks/server.test.ts | 30 ++++++ src/main/agent-hooks/server.ts | 12 ++- src/main/claude/hook-service.test.ts | 3 + src/main/claude/hook-settings.ts | 3 + ...ession-tab-agent-status-projection.test.ts | 60 +++++++++++ .../session-tab-agent-status-projection.ts | 33 ++++++ src/main/runtime/rpc/methods/session-tabs.ts | 49 +++++++-- .../activity/ActivityPrototypePage.test.ts | 19 ++++ .../activity/ActivityPrototypePage.tsx | 3 +- .../activity/useActivityUnreadCount.test.ts | 66 ++++++++++++ .../activity/useActivityUnreadCount.ts | 16 ++- .../automations/AutomationsPage.tsx | 23 ++-- ...automation-run-completion-evidence.test.ts | 88 +++++++++++++++ .../automation-run-completion-evidence.ts | 38 +++++++ .../dashboard/DashboardAgentRow.tsx | 19 +--- .../agent-finished-timestamp.test.ts | 39 +++++++ .../dashboard/agent-finished-timestamp.ts | 4 +- .../editor/ReviewNotesSendMenuContent.tsx | 14 +-- .../sidebar/smart-attention.test.ts | 18 ++++ .../src/components/sidebar/smart-attention.ts | 26 ++++- .../StarNagAgentValueMomentObserver.tsx | 3 + .../agent-completion-coordinator.ts | 7 ++ ...gent-hook-completion-notifications.test.ts | 37 +++++++ .../hooks/useAutomationDispatchEvents.test.ts | 29 +++++ .../src/hooks/useAutomationDispatchEvents.ts | 7 +- src/renderer/src/hooks/useIpcEvents.test.ts | 97 +++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 1 + .../src/store/slices/agent-status.test.ts | 100 ++++++++++++++++++ src/renderer/src/store/slices/agent-status.ts | 36 ++++++- .../src/web/web-runtime-client.test.ts | 10 +- src/renderer/src/web/web-runtime-client.ts | 10 +- src/shared/agent-hook-listener.test.ts | 90 ++++++++++++++++ src/shared/agent-hook-listener.ts | 43 +++++++- src/shared/agent-status-types.test.ts | 14 +++ src/shared/agent-status-types.ts | 9 ++ src/shared/protocol-version.ts | 3 + src/shared/remote-runtime-client.test.ts | 10 +- src/shared/remote-runtime-client.ts | 15 ++- .../remote-runtime-request-connection.test.ts | 15 ++- .../remote-runtime-request-connection.ts | 10 +- ...-runtime-shared-control-connection.test.ts | 10 +- ...te-runtime-shared-control-frame-handler.ts | 10 +- 43 files changed, 1046 insertions(+), 89 deletions(-) create mode 100644 src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts create mode 100644 src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts create mode 100644 src/renderer/src/components/activity/useActivityUnreadCount.test.ts create mode 100644 src/renderer/src/components/automations/automation-run-completion-evidence.test.ts create mode 100644 src/renderer/src/components/automations/automation-run-completion-evidence.ts create mode 100644 src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 2764a239021..7cba4d1cdc1 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -17,6 +17,7 @@ import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY @@ -61,7 +62,10 @@ describe('CLI remote WebSocket transport', () => { expect(response.result.runtimeId).toBe('runtime-ws-1') expect(runtime.authFrames).toContainEqual( expect.objectContaining({ - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) ) }) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 96bb121c7e7..c1870d35a7c 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -2257,6 +2257,36 @@ describe('AgentHookServer listener replay', () => { } }) + it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' })) + }) + + await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) + server.retirePaneAuthority(PANE) + + // Why: `claude --resume` in the reused shell pane emits only SessionStart while idle; + // without the un-retire the resumed session stays rowless until a prompt (STA-3386). + await postHook({ hook_event_name: 'SessionStart', source: 'resume' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'done', sessionBoundary: true }) + ]) + } finally { + server.stop() + } + }) + it('accepts a live remote prompt but not replay after launch authority retires', () => { const server = new AgentHookServer() server.ingestRemote( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 0f9e0a7812d..013b90d7143 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -415,7 +415,10 @@ function equivalentParsedAgentStatusPayload( a.toolInput === b.toolInput && a.interactivePrompt === b.interactivePrompt && a.lastAssistantMessage === b.lastAssistantMessage && - a.interrupted === b.interrupted + a.interrupted === b.interrupted && + // Why: a session-boundary done must never be deduped against a cached real done — + // the flag has to reach receivers deterministically (STA-3386). + a.sessionBoundary === b.sessionBoundary ) } @@ -996,7 +999,12 @@ export class AgentHookServer { return 'accept' } // Why: command completion retires launch authority but leaves its shell pane reusable. - if (event?.hookEventName === 'UserPromptSubmit' && event.isReplay !== true) { + // A live SessionStart proves a new agent process owns the retired pane just like a + // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + if ( + (event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') && + event.isReplay !== true + ) { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.delete(ownerPaneKey) return 'restart' diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index cd31d5577f5..0d390c3a9e3 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -378,6 +378,9 @@ describe('ClaudeHookService.installRemote', () => { // Code rejects unknown shapes silently and the agent-hooks pipeline // goes dark. for (const event of [ + // Why: SessionStart is the only signal a resumed/idle session ever emits; + // without it the sidebar row waits for the first prompt (STA-3386). + 'SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index b13c98ed6a9..de932276545 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -28,6 +28,9 @@ export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { } export const CLAUDE_EVENTS = [ + // Why: SessionStart is the only event a resumed/idle session emits before the + // first prompt; without it the sidebar row can't exist until the user types (STA-3386). + { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, // Why: OpenClaude skips normal Stop hooks after API/model errors and emits diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts new file mode 100644 index 00000000000..45f6613e354 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' +import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' + +function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapshot { + return { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'tab-1::leaf-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::leaf-1', + title: 'Claude', + parentTabId: 'tab-1', + leafId: 'leaf-1', + isActive: true, + agentStatus: { + state: 'done', + prompt: '', + updatedAt: 100, + stateStartedAt: 100, + paneKey: 'tab-1:leaf-1', + stateHistory: [], + sessionBoundary + } + } + ] + } +} + +describe('projectSessionTabAgentStatus', () => { + it('withholds session boundaries from legacy paired clients', () => { + const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', []) + + expect(projected.tabs[0]).not.toHaveProperty('agentStatus') + }) + + it('publishes session boundaries to clients that negotiated them', () => { + const snapshot = makeSnapshot(true) + + expect( + projectSessionTabAgentStatus(snapshot, 'runtime', [AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY]) + ).toBe(snapshot) + }) + + it('does not alter local, mobile, or real-completion projections', () => { + const localBoundary = makeSnapshot(true) + const mobileBoundary = makeSnapshot(true) + const runtimeCompletion = makeSnapshot(false) + + expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined)).toBe(localBoundary) + expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [])).toBe(mobileBoundary) + expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [])).toBe(runtimeCompletion) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts new file mode 100644 index 00000000000..125e083db4f --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -0,0 +1,33 @@ +import type { RuntimeCapability } from '../../../../shared/protocol-version' +import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../../../shared/runtime-types' + +type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot + +export function projectSessionTabAgentStatus( + payload: TPayload, + clientKind: 'mobile' | 'runtime' | undefined, + clientCapabilities: readonly RuntimeCapability[] | undefined +): TPayload { + // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. + if ( + clientKind !== 'runtime' || + clientCapabilities?.includes(AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY) + ) { + return payload + } + + let changed = false + const tabs = payload.tabs.map((tab) => { + if (tab.type !== 'terminal' || !tab.agentStatus?.sessionBoundary) { + return tab + } + changed = true + const { agentStatus: _boundary, ...legacyTab } = tab + return legacyTab + }) + return changed ? ({ ...payload, tabs } as TPayload) : payload +} diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 14716e0bf17..397753d609a 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -12,19 +12,26 @@ import { WorktreeTabSelector } from './session-tabs-schemas' import { SESSION_TAB_CLOSE_METHODS } from './session-tab-close-methods' +import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, - handler: async (params, { runtime, pairedDeviceId }) => - runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) + handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => + projectSessionTabAgentStatus( + await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), + clientKind, + clientCapabilities + ) }), defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, { runtime, pairedDeviceId }) => ({ - snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) + handler: async (_params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => ({ + snapshots: (await runtime.listAllMobileSessionTabs(pairedDeviceId)).map((snapshot) => + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + ) }) }), defineMethod({ @@ -128,7 +135,11 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribe', params: WorktreeTabSelector, - handler: async (params, { runtime, connectionId, requestId, pairedDeviceId }, emit) => { + handler: async ( + params, + { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, + emit + ) => { let subscribedWorktree: string | null = null let unsubscribe = (): void => {} let closed = false @@ -156,7 +167,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (closed) { return } - emit({ type: 'snapshot', ...initial }) + emit({ + type: 'snapshot', + ...projectSessionTabAgentStatus(initial, clientKind, clientCapabilities) + }) initialized = true if (closed) { return @@ -164,7 +178,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { if (snapshot.worktree === subscribedWorktree) { - emit({ type: 'updated', ...snapshot }) + emit({ + type: 'updated', + ...projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + }) } }, pairedDeviceId) if (closed) { @@ -193,7 +210,11 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async (_params, { runtime, connectionId, requestId, pairedDeviceId }, emit) => { + handler: async ( + _params, + { runtime, connectionId, requestId, pairedDeviceId, clientKind, clientCapabilities }, + emit + ) => { let unsubscribe = (): void => {} let closed = false // Why: initial listAll errors should return one RPC error, not a leaked @@ -227,14 +248,22 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ if (closed) { return } - emit({ type: 'snapshots', snapshots }) + emit({ + type: 'snapshots', + snapshots: snapshots.map((snapshot) => + projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + ) + }) initialized = true if (closed) { return } unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - emit({ type: 'updated', ...snapshot }) + emit({ + type: 'updated', + ...projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities) + }) }, pairedDeviceId) } }), diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts index b9e03ecc492..05aec890947 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.test.ts @@ -261,6 +261,25 @@ describe('buildActivityEvents', () => { expect(threads[0].events[0].entry.prompt).toBe('First prompt') }) + it('does not turn a session boundary into an Agent finished event', () => { + const result = makeActivityResult({ + entries: { + [PANE_KEY]: { + ...makeWorkingEntryWithoutHistory(), + state: 'done', + prompt: '', + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'Real turn', startedAt: 1_000 }] + } + } + }) + + // Why: the displaced real completion stays visible; the idle SessionStart does not add a second finish. + expect(result.events).toHaveLength(1) + expect(result.events[0]).toMatchObject({ state: 'done', timestamp: 1_000 }) + expect(result.events[0].entry.prompt).toBe('Real turn') + }) + it('does not keep showing a stale live agent as running', () => { const result = makeActivityResult({ entries: { diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 8e732ee044c..8c8058fbeeb 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -590,7 +590,8 @@ function appendActivityEventsForEntry(args: { }) } - if (!isActivityEventState(args.entry.state)) { + // Why: SessionStart creates an idle row, not an "Agent finished" activity event (STA-3386). + if (!isActivityEventState(args.entry.state) || args.entry.sessionBoundary === true) { return } appendActivityEvent({ diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.test.ts b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts new file mode 100644 index 00000000000..5c28a132762 --- /dev/null +++ b/src/renderer/src/components/activity/useActivityUnreadCount.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { countActivityUnread } from './useActivityUnreadCount' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function makeEntry(overrides: Partial): AgentStatusEntry { + return { + state: 'done', + prompt: '', + updatedAt: 2_000, + stateStartedAt: 2_000, + paneKey: PANE, + agentType: 'claude', + stateHistory: [], + ...overrides + } +} + +function makeSource(entry: AgentStatusEntry, ackAt = 0) { + return { + acknowledgedAgentsByPaneKey: { [PANE]: ackAt }, + agentStatusByPaneKey: { [PANE]: entry }, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + worktreesByRepo: {} + } +} + +describe('countActivityUnread session-boundary rows (STA-3386)', () => { + it('does not count a session-boundary done as unread in either mode', () => { + const source = makeSource(makeEntry({ sessionBoundary: true })) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) + expect(countActivityUnread(source, 'agent-events')).toBe(0) + }) + + it('keeps counting a real completion displaced into history by a session boundary', () => { + // Why: agent finished (unacknowledged), then the user resumed the session — the + // boundary row replaces the live done but the finish must stay unread in both badges. + const source = makeSource( + makeEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }] + }) + ) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + expect(countActivityUnread(source, 'agent-events')).toBe(1) + }) + + it('stops counting the displaced completion once acknowledged', () => { + const source = makeSource( + makeEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }] + }), + 1_500 + ) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(0) + expect(countActivityUnread(source, 'agent-events')).toBe(0) + }) + + it('still counts an ordinary unacknowledged done in sidebar-badge mode', () => { + const source = makeSource(makeEntry({})) + expect(countActivityUnread(source, 'sidebar-badge')).toBe(1) + }) +}) diff --git a/src/renderer/src/components/activity/useActivityUnreadCount.ts b/src/renderer/src/components/activity/useActivityUnreadCount.ts index b496f9146cd..91b51c4455e 100644 --- a/src/renderer/src/components/activity/useActivityUnreadCount.ts +++ b/src/renderer/src/components/activity/useActivityUnreadCount.ts @@ -60,8 +60,22 @@ export function countActivityUnread( } } } - if (isUnreadAgentState(entry.state) && ackAt < entry.stateStartedAt) { + // Why: a session-boundary done is an idle connect (STA-3386), not an event to read. + // History never contains a boundary, but it DOES keep the real completion a boundary + // displaced (the slice pushes it on done→done), so sidebar-badge mode — which skips the + // history loop above — must still count that displaced completion or the badge silently + // drops an unacknowledged finish the moment its session is resumed. + if ( + isUnreadAgentState(entry.state) && + entry.sessionBoundary !== true && + ackAt < entry.stateStartedAt + ) { count += 1 + } else if (mode === 'sidebar-badge' && entry.state === 'done' && entry.sessionBoundary) { + const displaced = entry.stateHistory.at(-1) + if (displaced && isUnreadAgentState(displaced.state) && ackAt < displaced.startedAt) { + count += 1 + } } } diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 97f15d844a6..e8a33f4dcd0 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -46,12 +46,12 @@ import { waitForAutomationRerunPendingVisibility } from './automation-run-view-state' import { - automationRunMatchesPaneKey, buildAutomationRunOpenLayout, canOpenAutomationRunOpenTarget, getAutomationRunOpenTabId, resolveAutomationRunOpenTarget } from './automation-run-open-target' +import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' import { AutomationEditorDialog, @@ -903,21 +903,12 @@ export default function AutomationsPage(): React.JSX.Element { if (dispatchedAt === null) { return false } - const liveDone = Object.entries(agentStatusByPaneKey).some( - ([paneKey, entry]) => - automationRunMatchesPaneKey(run, paneKey) && - entry.state === 'done' && - entry.updatedAt >= dispatchedAt - ) - if (liveDone) { - return true - } - return Object.entries(retainedAgentsByPaneKey).some( - ([paneKey, retained]) => - automationRunMatchesPaneKey(run, paneKey) && - retained.entry.state === 'done' && - retained.entry.updatedAt >= dispatchedAt - ) + return hasAutomationRunCompletionEvidence({ + run, + dispatchedAt, + agentStatusByPaneKey, + retainedAgentsByPaneKey + }) }) if (completedRuns.length === 0) { return diff --git a/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts b/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts new file mode 100644 index 00000000000..f46426337a8 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-completion-evidence.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationRun } from '../../../../shared/automations-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' +import { hasAutomationRunCompletionEvidence } from './automation-run-completion-evidence' + +const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + +function run(): AutomationRun { + return { + id: 'run-1', + automationId: 'automation-1', + title: 'Run 1', + scheduledFor: 1, + status: 'dispatched', + trigger: 'manual', + workspaceId: 'wt-1', + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: 'tab-1', + terminalPaneKey: PANE, + terminalPtyId: 'pty-1', + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: 1_000, + dispatchedAt: 1_000, + createdAt: 1_000 + } +} + +function entry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: PANE, + state: 'done', + prompt: '', + updatedAt: 2_000, + stateStartedAt: 2_000, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +function retained(status: AgentStatusEntry): RetainedAgentEntry { + return { + entry: status, + worktreeId: 'wt-1', + tab: { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1_000 + }, + agentType: 'claude', + startedAt: status.stateStartedAt + } +} + +describe('automation run completion evidence (STA-3386)', () => { + it('ignores live and retained session-boundary done rows', () => { + const boundary = entry({ sessionBoundary: true }) + expect( + hasAutomationRunCompletionEvidence({ + run: run(), + dispatchedAt: 1_000, + agentStatusByPaneKey: { [PANE]: boundary }, + retainedAgentsByPaneKey: { [PANE]: retained(boundary) } + }) + ).toBe(false) + }) + + it('accepts a real done after dispatch', () => { + expect( + hasAutomationRunCompletionEvidence({ + run: run(), + dispatchedAt: 1_000, + agentStatusByPaneKey: { [PANE]: entry() }, + retainedAgentsByPaneKey: {} + }) + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/automations/automation-run-completion-evidence.ts b/src/renderer/src/components/automations/automation-run-completion-evidence.ts new file mode 100644 index 00000000000..26c0b0ffc93 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-completion-evidence.ts @@ -0,0 +1,38 @@ +import type { AutomationRun } from '../../../../shared/automations-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' +import { automationRunMatchesPaneKey } from './automation-run-open-target' + +function isCompletionAfterDispatch(entry: AgentStatusEntry, dispatchedAt: number): boolean { + return entry.state === 'done' && entry.sessionBoundary !== true && entry.updatedAt >= dispatchedAt +} + +export function hasAutomationRunCompletionEvidence({ + run, + dispatchedAt, + agentStatusByPaneKey, + retainedAgentsByPaneKey +}: { + run: AutomationRun + dispatchedAt: number + agentStatusByPaneKey: Record + retainedAgentsByPaneKey: Record +}): boolean { + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { + if ( + automationRunMatchesPaneKey(run, paneKey) && + isCompletionAfterDispatch(entry, dispatchedAt) + ) { + return true + } + } + for (const [paneKey, retained] of Object.entries(retainedAgentsByPaneKey)) { + if ( + automationRunMatchesPaneKey(run, paneKey) && + isCompletionAfterDispatch(retained.entry, dispatchedAt) + ) { + return true + } + } + return false +} diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 60724a27dcf..460c251b98b 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -12,6 +12,7 @@ import type { AgentStatusState } from '../../../../shared/agent-status-types' import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { useAgentRowConversationName } from './use-agent-row-conversation-name' +import { lastEnteredDoneAt } from './agent-finished-timestamp' // Why: narrow the dashboard's rollup states to shared dot states, defaulting unknowns to 'idle' so a row never crashes. function asDotState(state: AgentStatusState | 'idle'): AgentDotState { @@ -43,24 +44,6 @@ function formatTimeAgo(ts: number, now: number): string { return `${days}d ago` } -// Why: use stateStartedAt (not updatedAt, which drifts on within-state pings) for the true done-transition time. -function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { - // Why: idle subagents are alive-but-idle (persist between turns); don't label them as done. - if (agent.rowSource === 'subagent' && agent.state === 'idle') { - return null - } - const entry = agent.entry - if (entry.state === 'done') { - return entry.stateStartedAt - } - for (let i = entry.stateHistory.length - 1; i >= 0; i--) { - if (entry.stateHistory[i].state === 'done') { - return entry.stateHistory[i].startedAt - } - } - return null -} - function stateDotTooltipLabel(agent: DashboardAgentRowData, dotState: AgentDotState): string { if (agent.entry.interrupted === true) { return 'Interrupted by user' diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts new file mode 100644 index 00000000000..92df277ef9d --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { lastEnteredDoneAt } from './agent-finished-timestamp' + +function row(entry: AgentStatusEntry) { + return { rowSource: 'live' as const, state: entry.state, entry } +} + +function doneEntry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + state: 'done', + prompt: '', + updatedAt: 2_000, + stateStartedAt: 2_000, + stateHistory: [], + agentType: 'claude', + ...overrides + } +} + +describe('lastEnteredDoneAt session boundaries (STA-3386)', () => { + it('does not report an idle SessionStart as a completed turn', () => { + expect(lastEnteredDoneAt(row(doneEntry({ sessionBoundary: true })))).toBeNull() + }) + + it('falls through to a real completion displaced into history', () => { + expect( + lastEnteredDoneAt( + row( + doneEntry({ + sessionBoundary: true, + stateHistory: [{ state: 'done', prompt: 'Real turn', startedAt: 1_000 }] + }) + ) + ) + ).toBe(1_000) + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts index 2c81cec0be2..6449e570fe1 100644 --- a/src/renderer/src/components/dashboard/agent-finished-timestamp.ts +++ b/src/renderer/src/components/dashboard/agent-finished-timestamp.ts @@ -15,7 +15,9 @@ export function lastEnteredDoneAt( return null } const entry = agent.entry - if (entry.state === 'done') { + // Why: a session-boundary done means the session connected idle (STA-3386) — nothing + // finished; fall through to history (which never records session boundaries). + if (entry.state === 'done' && entry.sessionBoundary !== true) { return entry.stateStartedAt } for (let i = (entry.stateHistory?.length ?? 0) - 1; i >= 0; i--) { diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index 25b55305c06..adb0c3d1ed8 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -28,6 +28,7 @@ import { import { track } from '@/lib/telemetry' import { useNow } from '@/components/dashboard/useNow' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp' import { selectLivePtyIdsForWorktree } from '@/components/sidebar/worktree-card-status-inputs' import { useWorktreeAgentRows } from '@/components/sidebar/useWorktreeAgentRows' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -322,19 +323,6 @@ function formatAgentRelativeTime(agent: DashboardAgentRowData, now: number): str return startedAt > 0 ? `${formatTimeAgo(startedAt, now)}` : null } -function lastEnteredDoneAt(agent: DashboardAgentRowData): number | null { - const entry = agent.entry - if (entry.state === 'done') { - return entry.stateStartedAt - } - for (let i = entry.stateHistory.length - 1; i >= 0; i--) { - if (entry.stateHistory[i].state === 'done') { - return entry.stateHistory[i].startedAt - } - } - return null -} - function formatTimeAgo(ts: number, now: number): string { const delta = now - ts if (delta < 60_000) { diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index d32192d36a5..2c4324938cd 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -61,6 +61,7 @@ function makeEntry(overrides: Partial & { paneKey: string }): terminalTitle: overrides.terminalTitle, stateHistory: overrides.stateHistory ?? [], interrupted: overrides.interrupted, + sessionBoundary: overrides.sessionBoundary, restoredUnconfirmed: overrides.restoredUnconfirmed } } @@ -177,6 +178,23 @@ describe('resolveAttention', () => { expect(resolveAttention([hookPane(entry)], NOW)).toEqual(IDLE) }) + it('treats a session boundary as idle unless it displaced a real completion', () => { + const boundary = makeEntry({ + paneKey: 't:1', + state: 'done', + sessionBoundary: true, + stateStartedAt: NOW - 1_000, + updatedAt: NOW - 500 + }) + expect(resolveAttention([hookPane(boundary)], NOW)).toEqual(IDLE) + + boundary.stateHistory = [makeHistory('done', NOW - 90_000)] + expect(resolveAttention([hookPane(boundary)], NOW)).toEqual({ + cls: 2, + attentionTimestamp: NOW - 90_000 + }) + }) + it('classifies a working pane with prior done as Class 3 with the prior timestamp', () => { const entry = makeEntry({ paneKey: 't:1', diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 6c102a41405..760fd9d16ca 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -98,6 +98,21 @@ export function mostRecentAttentionInHistory(history: AgentStateHistoryEntry[]): return max > 0 ? max : null } +function mostRecentCompletedTurnInHistory(history: AgentStateHistoryEntry[]): number | null { + let max = 0 + for (const entry of history) { + if ( + entry.state === 'done' && + entry.interrupted !== true && + Number.isFinite(entry.startedAt) && + entry.startedAt > max + ) { + max = entry.startedAt + } + } + return max > 0 ? max : null +} + /** * One pane's contribution to a worktree's attention class. Fresh hook entries win; hookless * panes fall back to the title heuristic (design doc Edge case 9). Authority is per-pane, not per-worktree. @@ -142,7 +157,16 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten continue } cls = 2 - ts = entry.stateStartedAt + if (entry.sessionBoundary) { + // Why: idle connect is not new attention; only preserve a real completion it displaced. + const completedAt = mostRecentCompletedTurnInHistory(entry.stateHistory) + if (completedAt === null) { + continue + } + ts = completedAt + } else { + ts = entry.stateStartedAt + } } else { // working cls = 3 diff --git a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx index 1fd3099323d..42f6145b1f3 100644 --- a/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx +++ b/src/renderer/src/components/star-nag/StarNagAgentValueMomentObserver.tsx @@ -33,6 +33,9 @@ function hasSuccessfulDoneTransition( previousEntry && previousEntry.state !== 'done' && entry.state === 'done' && + // Why: a session-boundary done is an idle connect (STA-3386), not a value moment — + // a stale working row + resume would otherwise nag on launch. + entry.sessionBoundary !== true && !entry.interrupted && hasMeaningfulPrompt(entry) ) { diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index b948cf43da6..b3059ca6390 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -810,6 +810,13 @@ export function createAgentCompletionCoordinator( dispatchAttention(payload) return } + if (payload.state === 'done' && payload.sessionBoundary === true) { + // Why: a session-boundary 'done' is an idle session connecting (resume/launch/clear, + // STA-3386), not a completed turn — record the activity/evidence above but never + // complete. Pending timers stay untouched: a quiet-window done from a real prior + // turn must still fire. + return + } if (isCompletionHookState(payload.state)) { // Why: the turn is ending, so a debounced attention from an earlier pause must not fire after completion. clearPendingCodexAttention() diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 228af0058eb..4803731fb70 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -421,6 +421,43 @@ describe('agent hook completion notifications', () => { ) }) + it('does not fire a completion notification for a session-boundary done row', async () => { + const { observeAgentHookCompletionForNotification } = + await import('./agent-hook-completion-notifications') + + // Why: Claude SessionStart lands as a sessionBoundary 'done' so a resumed session gets + // its sidebar row while idle (STA-3386) — connecting to a session is not completing a turn. + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true, + stateStartedAt: 1_700_000_000_000 + } + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + // Why: the resumed session's next real turn must still notify normally. + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { ...hookStatus('working'), agentType: 'claude' } + }) + observeAgentHookCompletionForNotification({ + paneKey, + worktreeId: 'wt-1', + payload: { ...hookStatus('done'), agentType: 'claude', stateStartedAt: 1_700_000_020_000 } + }) + vi.advanceTimersByTime(HOOK_DONE_QUIET_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + }) + it('does not notify twice when the same done hook snapshot replays after activation', async () => { const { observeAgentHookCompletionForNotification } = await import('./agent-hook-completion-notifications') diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts index 160496c3be8..d49778b3a05 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.test.ts @@ -539,6 +539,35 @@ describe('useAutomationDispatchEvents setup launch', () => { }) }) + it('ignores a session-boundary done so a connecting agent cannot complete the run (STA-3386)', async () => { + let launchArgs: { + onAgentStatus?: (payload: { state: string; sessionBoundary?: boolean }) => void + } = {} + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + launchArgs = args + return { + tabId: 'agent-tab', + paneKey: 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d', + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + // Why: Claude fires SessionStart (a sessionBoundary done) at launch, before the argv + // prompt submits — treating it as run completion would close the tab on an empty run. + launchArgs.onAgentStatus?.({ state: 'done', sessionBoundary: true }) + await Promise.resolve() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + + launchArgs.onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + }) + it('consumes duplicate done and zero-exit completion through one finalizer', async () => { let launchArgs: { onAgentStatus?: (payload: { state: string }) => void diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.ts index d543d639ddf..09009d40011 100644 --- a/src/renderer/src/hooks/useAutomationDispatchEvents.ts +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.ts @@ -416,6 +416,10 @@ export function useAutomationDispatchEvents(): void { } if ( entry.state === 'done' && + // Why: a session-boundary done is the agent CONNECTING (Claude SessionStart + // fires at launch, before the argv prompt submits) — completing here would + // close the tab and record an empty run result. + entry.sessionBoundary !== true && (!options?.requireWorkingAfterStart || sawWorkingAfterStart) ) { latestAssistantMessage = @@ -528,7 +532,8 @@ export function useAutomationDispatchEvents(): void { onAgentStatus: (payload) => { latestAssistantMessage = payload.lastAssistantMessage?.trim() || latestAssistantMessage - if (payload.state !== 'done') { + // Why: session-boundary done = launch connect, not run completion (see observeAgentStatus). + if (payload.state !== 'done' || payload.sessionBoundary === true) { return } handleAgentDone() diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index c13cf20057a..e2d831c9da4 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -16,6 +16,12 @@ import { loadIpcEventsHarness, type HarnessStoreState } from './ipc-events-test-harness' +import { + createTestStore, + makeTab, + makeWorktree, + TEST_REPO +} from '../store/slices/store-test-helpers' import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' import type { TuiAgent } from '../../../shared/types' @@ -4949,6 +4955,7 @@ describe('useIpcEvents agent status snapshot integration', () => { toolInput?: string lastAssistantMessage?: string interrupted?: boolean + sessionBoundary?: boolean terminalHandle?: string launchToken?: string providerSession?: { key: 'session_id'; id: string } @@ -7842,6 +7849,96 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('preserves a SessionStart boundary from snapshot IPC without completion side effects', async () => { + const now = Date.now() + const refreshGitHubForWorktreeIfStale = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() + const store = createTestStore() + store.setState({ + workspaceSessionReady: true, + repos: [{ ...TEST_REPO, connectionId: null, executionHostId: 'local' }], + worktreesByRepo: { + [TEST_REPO.id]: [makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id })] + }, + tabsByWorktree: { + 'wt-1': [ + makeTab({ + id: 'tab-future', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'Claude' + }) + ] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + }, + refreshGitHubForWorktreeIfStale + }) + store + .getState() + .setAgentStatus( + FUTURE_PANE_KEY, + { state: 'working', prompt: 'resumed task', agentType: 'claude' }, + 'Claude', + { updatedAt: now - 2, stateStartedAt: now - 2 }, + { tabId: 'tab-future', worktreeId: 'wt-1' } + ) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: store.subscribe, + getState: store.getState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn(), + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot: () => + Promise.resolve([ + { + paneKey: FUTURE_PANE_KEY, + tabId: 'tab-future', + worktreeId: 'wt-1', + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true, + receivedAt: now, + stateStartedAt: now + } + ]), + onSet: () => () => {} + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + await vi.waitFor(() => { + expect(store.getState().agentStatusByPaneKey[FUTURE_PANE_KEY]).toMatchObject({ + state: 'done', + sessionBoundary: true + }) + }) + await Promise.resolve() + + expect(refreshGitHubForWorktreeIfStale).not.toHaveBeenCalled() + expect(observeAgentHookCompletionForNotification).not.toHaveBeenCalled() + }) + it('queues replayed startup snapshots until the pane hydrates', async () => { const setAgentStatus = vi.fn() const subscribeListenerRef: { current: StoreSubscribeListener | null } = { current: null } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index bed3e4e86c8..5b719c72420 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -3100,6 +3100,7 @@ export function useIpcEvents(): void { interactivePrompt: data.interactivePrompt, lastAssistantMessage: data.lastAssistantMessage, interrupted: data.interrupted, + sessionBoundary: data.sessionBoundary, // Why: same trap as interactivePrompt — this rebuild is a field whitelist, so subagent child rows vanish if omitted. subagents: data.subagents }) diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index b6b2976e26b..6965ca8bbb2 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -1399,3 +1399,103 @@ describe('agent status retention + prefix sweep', () => { expect(retained['tab-b:0']).toBeUndefined() }) }) + +describe('session-boundary done semantics (STA-3386)', () => { + const PANE = 'tab-1:11111111-1111-4111-8111-111111111111' + + it('keeps the launch config registry entry alive across a session-boundary done', () => { + const store = createTestStore() + store.setState({ + agentLaunchConfigByPaneKey: { + [PANE]: { + launchConfig: { agentArgs: '', agentEnv: {} }, + identity: { agentType: 'claude' }, + launchToken: 'token-1' + } + } + } as unknown as Partial) + + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + // Why: the session just CONNECTED — the registered-launch-agent identity must survive + // or an idle resumed TUI loses its pane identity evidence at startup. + expect(store.getState().agentLaunchConfigByPaneKey[PANE]).toBeDefined() + + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: 'fix bug', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + expect(store.getState().agentLaunchConfigByPaneKey[PANE]).toBeUndefined() + }) + + it('pushes a real completion into history when a session boundary lands on it', () => { + const store = createTestStore() + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'fix bug', agentType: 'claude' }) + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: 'fix bug', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + + const entry = store.getState().agentStatusByPaneKey[PANE] + expect(entry.sessionBoundary).toBe(true) + // Why: the finished timestamp and unread badge fall through to history for boundary + // entries — losing the real done here erases an unacknowledged completion. + expect(entry.stateHistory.some((h) => h.state === 'done')).toBe(true) + }) + + it('never records a session boundary itself in state history', () => { + const store = createTestStore() + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + store + .getState() + .setAgentStatus(PANE, { state: 'working', prompt: 'fix bug', agentType: 'claude' }) + + const entry = store.getState().agentStatusByPaneKey[PANE] + expect(entry.stateHistory.some((h) => h.state === 'done')).toBe(false) + }) + + it('carries the flag across metadata-less done repaints but yields to turn evidence', () => { + const store = createTestStore() + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + + // Why: OSC 9999 repaints and reconnect replays re-deliver a metadata-less done. + store.getState().setAgentStatus(PANE, { state: 'done', prompt: '', agentType: 'claude' }) + expect(store.getState().agentStatusByPaneKey[PANE].sessionBoundary).toBe(true) + + // Why: an assistant message proves a REAL completion — the flag must not suppress it. + store.getState().setAgentStatus(PANE, { + state: 'done', + prompt: '', + agentType: 'claude', + lastAssistantMessage: 'Done.' + }) + expect(store.getState().agentStatusByPaneKey[PANE].sessionBoundary).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index fcf39188136..271ab70829a 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1739,8 +1739,22 @@ export const createAgentStatusSlice: StateCreator { expect(JSON.parse(auth!)).toEqual({ type: 'e2ee_auth', deviceToken: 'token', - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) client.close() diff --git a/src/renderer/src/web/web-runtime-client.ts b/src/renderer/src/web/web-runtime-client.ts index 3ed33e18887..a51d93ac0d3 100644 --- a/src/renderer/src/web/web-runtime-client.ts +++ b/src/renderer/src/web/web-runtime-client.ts @@ -14,7 +14,10 @@ import { publicKeyFromBase64, publicKeyToBase64 } from './web-e2ee' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { createWebRuntimeUnauthorizedError } from './web-runtime-client-error' import { withReconnectJitter } from '../../../shared/reconnect-jitter' @@ -456,7 +459,10 @@ export class WebRuntimeClient { this.sendEncrypted({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken, - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) return } diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 73c1aae3a6d..edab2cb88f6 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -1369,6 +1369,96 @@ describe('shared agent-hook-listener', () => { expect(event?.payload.lastAssistantMessage).toBeUndefined() }) + it('maps Claude SessionStart to an idle done row so a resumed session earns its sidebar row before the first prompt', () => { + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'SessionStart', + source: 'resume', + session_id: '44444444-4444-4444-8444-444444444444' + } + }, + 'production' + ) + + // Why: 'working' would show a phantom spinner on an idle TUI; a session-boundary + // 'done' renders the row idle, which is the truth at SessionStart. + expect(event?.payload).toMatchObject({ + state: 'done', + prompt: '', + agentType: 'claude', + sessionBoundary: true + }) + expect(event?.payload.interrupted).toBeUndefined() + expect(event?.hookEventName).toBe('SessionStart') + // Why: SessionStart carries resume identity, so in-app resume works before any prompt. + expect(event?.providerSession).toMatchObject({ + key: 'session_id', + id: '44444444-4444-4444-8444-444444444444' + }) + }) + + it('resets stale Claude turn state when SessionStart announces a new session on the pane', () => { + normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'fix bug' }) + normalizeAndAccept(state, 'claude', { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' } + }) + normalizeAndAccept(state, 'claude', { hook_event_name: 'SubagentStart', agent_id: 'agent-1' }) + + const event = normalizeAndAccept(state, 'claude', { + hook_event_name: 'SessionStart', + source: 'startup' + }) + + // Why: a new process owns the pane; stale prompt/tool/children must not survive + // into the fresh session's idle row or gate it back up to 'working'. + expect(event?.payload.state).toBe('done') + expect(event?.payload.prompt).toBe('') + expect(event?.payload.toolName).toBeUndefined() + expect(event?.payload.subagents).toBeUndefined() + }) + + it('keeps the running Claude turn when SessionStart comes from a compact restart or a child session', () => { + normalizeAndAccept(state, 'claude', { hook_event_name: 'UserPromptSubmit', prompt: 'say hi' }) + + const compacted = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { hook_event_name: 'SessionStart', source: 'compact' } }, + 'production' + ) + // Why: unknown/missing sources fail closed — only startup/resume/clear are idle boundaries. + const unknownSource = normalizeHookPayload( + state, + 'claude', + { paneKey: PANE_KEY, payload: { hook_event_name: 'SessionStart' } }, + 'production' + ) + const child = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', source: 'startup', agent_id: 'agent-7' } + }, + 'production' + ) + const stopped = normalizeAndAccept(state, 'claude', { hook_event_name: 'Stop' }) + + // Why: auto-compact restarts mid-turn (PreCompact/PostCompact own that lifecycle) and a + // child-attributed SessionStart must not flip the lead's live turn to an idle row. + expect(compacted).toBeNull() + expect(unknownSource).toBeNull() + expect(child).toBeNull() + expect(stopped?.payload).toMatchObject({ state: 'done', prompt: 'say hi' }) + expect(stopped?.payload.sessionBoundary).toBeUndefined() + }) + it('normalizes Devin documented lifecycle events', () => { const started = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 985f95b1876..9e133aa683a 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2405,9 +2405,11 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of falling through to false. switch (source) { case 'claude': - // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. - // falls through + // Why: SessionStart lands an idle row (STA-3386) and must also drop stale + // tool/prompt caches left by the pane's previous session. + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' case 'kimi': + // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. return eventName === 'UserPromptSubmit' case 'codex': return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' @@ -2739,6 +2741,35 @@ function normalizeClaudeEvent( ) { return normalizeClaudeSubagentLifecycleEvent(state, eventName, paneKey, hookPayload) } + if (eventName === 'SessionStart') { + // Why: SessionStart is the only signal a resumed session emits before its first prompt + // (STA-3386). Land it as a session-boundary 'done' row: 'working' would show a phantom + // spinner on an idle TUI (why Devin/Pi/Grok drop the event), and the sessionBoundary + // flag keeps completion-reactive consumers (notifications, automation runs) out of it. + const sessionStartSource = hookPayload['source'] + if ( + eventAgentId !== undefined || + (sessionStartSource !== 'startup' && + sessionStartSource !== 'resume' && + sessionStartSource !== 'clear') + ) { + // Why: allowlist idle boundaries and fail closed — a compact restart (or any unknown + // source) fires mid-turn, and a child-attributed SessionStart must not flip the lead's + // live turn to an idle row. + return null + } + // Why: a new process owns the pane; stale children/tasks/crons must not gate the + // fresh session's idle row back up to 'working' (same reset Codex does on SessionStart). + state.claudeSubagentRosterByPaneKey.delete(paneKey) + state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + state.claudeActiveSessionCronPaneKeys.delete(paneKey) + state.claudeLeadStateByPaneKey.set(paneKey, { state: 'done' }) + return buildClaudeStatusPayload(state, eventName, promptText, paneKey, hookPayload, { + stateName: 'done', + updateToolSnapshot: true, + sessionBoundary: true + }) + } const previousLead = state.claudeLeadStateByPaneKey.get(paneKey) // Why: only a turn boundary may declare an interrupt or carry a prior one forward; any other event starts a fresh turn and drops it. const isTurnBoundary = eventName === 'Stop' || eventName === 'StopFailure' @@ -2890,7 +2921,12 @@ function buildClaudeStatusPayload( promptText: string, paneKey: string, hookPayload: Record, - options: { stateName: AgentStatusState; updateToolSnapshot: boolean; interrupted?: boolean } + options: { + stateName: AgentStatusState + updateToolSnapshot: boolean + interrupted?: boolean + sessionBoundary?: boolean + } ): ParsedAgentStatusPayload | null { // Why: child-driven refreshes are roster bookkeeping, not lead tool activity; read the cached snapshot without merging so they can't clear a live AskUserQuestion card or clobber the tool preview. const snapshot = options.updateToolSnapshot @@ -2913,6 +2949,7 @@ function buildClaudeStatusPayload( interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, interrupted: options.interrupted, + sessionBoundary: options.sessionBoundary, subagents: claudeRosterToSnapshots(state.claudeSubagentRosterByPaneKey.get(paneKey)) }) } diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index 6059950b708..1dea3dc70fe 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -421,6 +421,20 @@ Fix dispatch fallback preview for normalized status prompts` } }) + it('preserves sessionBoundary=true only on done (stale-signal suppression like interrupted)', () => { + expect( + parseAgentStatusPayload('{"state":"done","sessionBoundary":true}')!.sessionBoundary + ).toBe(true) + for (const state of ['working', 'blocked', 'waiting'] as const) { + const result = parseAgentStatusPayload(`{"state":"${state}","sessionBoundary":true}`) + expect(result!.sessionBoundary).toBeUndefined() + } + // Why: parser uses `=== true`, so truthy sentinels don't count. + expect( + parseAgentStatusPayload('{"state":"done","sessionBoundary":"true"}')!.sessionBoundary + ).toBeUndefined() + }) + it('requires strict boolean true for interrupted (rejects truthy non-boolean)', () => { // Why: parser uses `=== true`, so truthy string/number sentinels don't count. expect( diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 0d902ecb0fc..123a1bf5eb7 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -126,6 +126,8 @@ export type AgentStatusEntry = { /** True when this `done` was reached via interrupt, not normal completion * (agent-reported or Orca's guarded fallback). Undefined otherwise. */ interrupted?: boolean + /** True when this `done` is a session boundary, not a completed turn. See AgentStatusPayload. */ + sessionBoundary?: boolean /** Orchestration dispatch context for panes spawned by another agent. * Why: parent/child hierarchy is pane-level state, not worktree lineage — workers often share the coordinator's worktree. */ orchestration?: AgentStatusOrchestrationContext @@ -170,6 +172,11 @@ export type AgentStatusPayload = { interactivePrompt?: string lastAssistantMessage?: string interrupted?: boolean + /** True when this `done` marks a session boundary (connect/resume/clear landing idle, + * e.g. Claude SessionStart — STA-3386), not a completed turn. Consumers that react to + * completions (notifications, automation runs, unread badges, finished timestamps) + * must ignore it. Only meaningful on `done`. */ + sessionBoundary?: boolean /** Live in-process children of the reporting session. See AgentStatusEntry. */ subagents?: AgentSubagentSnapshot[] } @@ -202,6 +209,7 @@ export function pickParsedAgentStatusPayload( ? { lastAssistantMessage: row.lastAssistantMessage } : {}), ...(row.interrupted !== undefined ? { interrupted: row.interrupted } : {}), + ...(row.sessionBoundary !== undefined ? { sessionBoundary: row.sessionBoundary } : {}), ...(row.subagents !== undefined ? { subagents: row.subagents } : {}) } } @@ -401,6 +409,7 @@ function normalizeAgentStatusObject(parsed: unknown): ParsedAgentStatusPayload | ), // Why: only meaningful on `done`; coerce to undefined elsewhere so it can't leak stale truth across transitions. interrupted: obj.interrupted === true && state === 'done' ? true : undefined, + sessionBoundary: obj.sessionBoundary === true && state === 'done' ? true : undefined, subagents: normalizeSubagentsField(obj.subagents) } } diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 3f39bf29e34..c967c66822b 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -72,6 +72,8 @@ export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentia export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const export const SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY = 'session-tabs.close-intent.v1' as const +export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = + 'agent-session.session-boundary.v1' as const export { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const @@ -108,6 +110,7 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-client.test.ts b/src/shared/remote-runtime-client.test.ts index 43a2a982d73..9ead47f4067 100644 --- a/src/shared/remote-runtime-client.test.ts +++ b/src/shared/remote-runtime-client.test.ts @@ -13,7 +13,10 @@ import { } from './e2ee-crypto' import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest } from './remote-runtime-client' import { MAX_TIMER_DELAY_MS } from './timer-delay' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' const servers: WebSocketServer[] = [] @@ -69,7 +72,10 @@ describe('subscribeRemoteRuntimeRequest', () => { await expect(server.nextAuth).resolves.toEqual({ type: 'e2ee_auth', deviceToken: 'device-token', - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) const bytes = new Uint8Array([1, 2, 3]) expect(subscription.sendBinary(bytes)).toBe(true) diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index bd424cd0852..f147ed4e7bb 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -22,7 +22,10 @@ import { type RuntimeRpcResponse } from './runtime-rpc-envelope' import type { RuntimeStatus } from './runtime-types' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' // Re-export so existing value importers of `RemoteRuntimeClientError` are // unaffected; the class lives in a ws-free module so type-only consumers // (and mobile's typecheck) don't compile this file's Node-only deps. @@ -134,7 +137,10 @@ async function sendRemoteRuntimeRequestOnSocket( const serializedAuth = serializeRemoteRuntimePayload({ type: 'e2ee_auth', deviceToken: pairing.deviceToken, - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) const pendingRequest = { preparedRequest: prepareRemoteRuntimeRequest(new Map(), () => @@ -507,7 +513,10 @@ export async function subscribeRemoteRuntimeRequest( const serializedAuth = serializeRemoteRuntimePayload({ type: 'e2ee_auth', deviceToken: pairing.deviceToken, - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) return await new Promise((resolve, reject) => { const keyPair = generateKeyPair() diff --git a/src/shared/remote-runtime-request-connection.test.ts b/src/shared/remote-runtime-request-connection.test.ts index 9bd5ff253d3..7f8f4e69871 100644 --- a/src/shared/remote-runtime-request-connection.test.ts +++ b/src/shared/remote-runtime-request-connection.test.ts @@ -11,7 +11,10 @@ import { publicKeyToBase64 } from './e2ee-crypto' import { RemoteRuntimeRequestConnection } from './remote-runtime-request-connection' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' type TestServer = { wss: WebSocketServer @@ -58,7 +61,10 @@ describe('RemoteRuntimeRequestConnection', () => { expect(server.connectionCount()).toBe(1) expect(server.auths).toContainEqual( expect.objectContaining({ - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) ) expect(server.requests).toMatchObject([ @@ -106,7 +112,10 @@ async function createServer(): Promise { expect(auth).toEqual({ type: 'e2ee_auth', deviceToken: 'device-token', - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) authenticated = true sendEncrypted(ws, sharedKey, { type: 'e2ee_authenticated' }) diff --git a/src/shared/remote-runtime-request-connection.ts b/src/shared/remote-runtime-request-connection.ts index 8ff1f246c8e..f79c5cdd136 100644 --- a/src/shared/remote-runtime-request-connection.ts +++ b/src/shared/remote-runtime-request-connection.ts @@ -30,7 +30,10 @@ import { type RemoteRuntimeRequestReadyWaiter } from './remote-runtime-request-ready-waiters' import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' type ConnectionState = 'closed' | 'awaiting_ready' | 'awaiting_authenticated' | 'ready' @@ -207,7 +210,10 @@ export class RemoteRuntimeRequestConnection { serializeRemoteRuntimePayload({ type: 'e2ee_auth', deviceToken: this.pairing.deviceToken, - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }), sharedKey ) diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index 43ec4473c2a..66a57a92e52 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -20,7 +20,10 @@ import { getRemoteRuntimeRequestAdmissionEvidence } from './remote-runtime-prepa import { RemoteRuntimeSharedControlConnection } from './remote-runtime-shared-control-connection' import * as sharedControlProtocol from './remote-runtime-shared-control-protocol' import { isRuntimeSubscriptionReplayResponse } from './runtime-subscription-replay' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' const TEST_PROJECT_PATH = path.join('tmp', 'project') @@ -62,7 +65,10 @@ describe('RemoteRuntimeSharedControlConnection', () => { expect(server.auths).toContainEqual({ type: 'e2ee_auth', deviceToken: 'device-token', - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) expect(server.requests.map((request) => request.method)).toEqual([ 'worktree.ps', diff --git a/src/shared/remote-runtime-shared-control-frame-handler.ts b/src/shared/remote-runtime-shared-control-frame-handler.ts index 3bf3c5cdcee..96a3e1084ad 100644 --- a/src/shared/remote-runtime-shared-control-frame-handler.ts +++ b/src/shared/remote-runtime-shared-control-frame-handler.ts @@ -1,6 +1,9 @@ import { parseAuthenticatedFrame, parseReadyFrame } from './remote-runtime-request-frames' import type { RemoteRuntimeClientError } from './remote-runtime-client-error' -import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from './protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY +} from './protocol-version' import { dispatchSharedControlFrame } from './remote-runtime-shared-control-frame-dispatch' import { parseSharedControlFrame } from './remote-runtime-shared-control-protocol' import type { SharedControlRetiredRequestIds } from './remote-runtime-shared-control-retired-request-ids' @@ -38,7 +41,10 @@ export function handleSharedControlTextFrame(args: { args.sendEncrypted({ type: 'e2ee_auth', deviceToken: args.deviceToken, - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) return } From 89f00658d256b90895921d150c02eae446538bfb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:08:40 -0700 Subject: [PATCH 31/46] fix(mobile): bound home host auto-connect fanout (#11642) * fix(mobile): bound home host auto-connect fanout * fix(mobile): clarify bounded host connection state * fix(mobile): close unused settings host clients * fix(mobile): close released host clients * fix(mobile): sync host release policy in effect * fix(mobile): preserve focused host client ownership * test(mobile): cover manual Home client release * Fix React Doctor array type check --- mobile/app/index.tsx | 24 +- mobile/app/terminal-settings.tsx | 4 +- mobile/app/voice-settings.tsx | 15 +- mobile/src/transport/client-context.test.ts | 178 ++++ mobile/src/transport/client-context.tsx | 190 +++-- .../transport/home-host-auto-connect.test.ts | 56 ++ .../src/transport/home-host-auto-connect.ts | 24 + .../host-client-acquisition-count.ts | 9 + .../transport/host-client-context-state.ts | 72 ++ .../settings-host-client-connections.ts | 19 + .../settings-host-client-lifecycle.test.ts | 776 ++++++++++++++++++ mobile/src/transport/use-all-host-clients.ts | 142 +++- 12 files changed, 1372 insertions(+), 137 deletions(-) create mode 100644 mobile/src/transport/home-host-auto-connect.test.ts create mode 100644 mobile/src/transport/home-host-auto-connect.ts create mode 100644 mobile/src/transport/host-client-acquisition-count.ts create mode 100644 mobile/src/transport/host-client-context-state.ts create mode 100644 mobile/src/transport/settings-host-client-connections.ts create mode 100644 mobile/src/transport/settings-host-client-lifecycle.test.ts diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 1a4681565e3..70da22348e6 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -27,6 +27,10 @@ import { createHostConnectRefetchGate } from '../src/transport/host-connect-refe import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context' import { useAllHostClients } from '../src/transport/use-all-host-clients' +import { + resolveHomeHostConnectionState, + selectHomeAutoConnectHostIds +} from '../src/transport/home-host-auto-connect' import { classifyConnection } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import { @@ -247,7 +251,11 @@ export default function HomeScreen() { const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) - const allClients = useAllHostClients(hostIds) + const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts]) + const allClients = useAllHostClients(hostIds, { + autoConnectHostIds, + closeUnusedOnRelease: true + }) const hostPaths = useMemo( () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), [allClients] @@ -745,7 +753,11 @@ export default function HomeScreen() { } ItemSeparatorComponent={CardGap} renderItem={({ item }) => { - const state = hostStates[item.id] ?? 'connecting' + const state = resolveHomeHostConnectionState( + item.id, + hostStates[item.id], + autoConnectHostIds + ) const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null const verdict = classifyConnection({ @@ -948,7 +960,13 @@ export default function HomeScreen() { message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined} actions={getHostListActionSheetActions({ host: actionTarget, - state: actionTarget ? (hostStates[actionTarget.id] ?? 'connecting') : 'disconnected', + state: actionTarget + ? resolveHomeHostConnectionState( + actionTarget.id, + hostStates[actionTarget.id], + autoConnectHostIds + ) + : 'disconnected', hasEverConnected: actionTarget ? (hostLastConnected[actionTarget.id] ?? null) != null : false, diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index e6aa691c76e..e55819aa22a 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' -import { useAllHostClients } from '../src/transport/use-all-host-clients' +import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings' @@ -127,7 +127,7 @@ export default function TerminalSettingsScreen() { void loadHosts().then(setHosts) }, []) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - const hostClients = useAllHostClients(hostIds) + const { clients: hostClients } = useFocusedSettingsHostClients(hostIds) const hostClientsById = useMemo( () => new Map(hostClients.map((entry) => [entry.hostId, entry.client])), [hostClients] diff --git a/mobile/app/voice-settings.tsx b/mobile/app/voice-settings.tsx index a09d964b388..8648a1d38e5 100644 --- a/mobile/app/voice-settings.tsx +++ b/mobile/app/voice-settings.tsx @@ -9,12 +9,12 @@ import { View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useFocusEffect, useRouter } from 'expo-router' +import { useRouter } from 'expo-router' import { ChevronLeft, ChevronRight } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' -import { useAllHostClients } from '../src/transport/use-all-host-clients' +import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' import type { RpcClient } from '../src/transport/rpc-client' import { BottomDrawer } from '../src/components/BottomDrawer' import { VoiceModelList } from '../src/components/VoiceModelList' @@ -47,7 +47,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element { void loadHosts().then(setHosts) }, []) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - const hostClients = useAllHostClients(hostIds) + const { clients: hostClients, focused: routeFocused } = useFocusedSettingsHostClients(hostIds) // Voice dictation runs on the paired desktop, so pick the first connected host. const client: RpcClient | null = useMemo( () => hostClients.find((entry) => entry.state === 'connected')?.client ?? null, @@ -59,15 +59,6 @@ export default function VoiceSettingsScreen(): React.JSX.Element { const [error, setError] = useState(null) const [busyAction, setBusyAction] = useState(null) const [modelDrawerOpen, setModelDrawerOpen] = useState(false) - const [routeFocused, setRouteFocused] = useState(false) - - useFocusEffect( - useCallback(() => { - setRouteFocused(true) - return () => setRouteFocused(false) - }, []) - ) - const refresh = useCallback(async (): Promise => { if (!client) { return false diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index e7c00008e05..b08899f5333 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -21,6 +21,8 @@ vi.mock('./connection-revival-triggers', () => ({ })) import { RpcClientProvider, useCloseHost, useForceReconnect, useHostClient } from './client-context' +import { useAllHostClients } from './use-all-host-clients' +import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' type FakeClient = RpcClient & { emitState: (state: ConnectionState) => void @@ -386,3 +388,179 @@ describe('useHostClient', () => { expect(connectMock).not.toHaveBeenCalled() }) }) + +describe('useAllHostClients', () => { + it('only opens the requested startup subset', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id, host2.id], { autoConnectHostIds: [host2.id] }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledOnce() + expect(connectMock).toHaveBeenCalledWith(host2, expect.any(Function)) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('keeps startup connection fanout constant for a large saved-host list', async () => { + const hosts = Array.from({ length: 1_000 }, (_, index) => ({ + ...HOST, + id: `host-${index}`, + name: `Host ${index}`, + lastConnected: index + })) + const hostIds = hosts.map((host) => host.id) + const autoConnectHostIds = selectHomeAutoConnectHostIds(hosts) + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue(hosts) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients(hostIds, { autoConnectHostIds }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledTimes(3) + expect(connectMock.mock.calls.map(([host]) => host.id)).toEqual([ + 'host-999', + 'host-998', + 'host-997' + ]) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('closes a demoted Home client when the recent-host set rotates', async () => { + const hosts = [ + { ...HOST, id: 'host-a', lastConnected: 4 }, + { ...HOST, id: 'host-b', lastConnected: 3 }, + { ...HOST, id: 'host-c', lastConnected: 2 }, + { ...HOST, id: 'host-d', lastConnected: 1 } + ] + const clients = new Map() + connectMock.mockImplementation((profile: typeof HOST) => { + const client = makeFakeClient('connected') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(hosts) + + let activeHostIds: string[] = [] + let renderer: ReactTestRenderer | null = null + function Probe({ profiles }: { profiles: typeof hosts }): null { + const hostIds = profiles.map((host) => host.id) + activeHostIds = useAllHostClients(hostIds, { + autoConnectHostIds: selectHomeAutoConnectHostIds(profiles), + closeUnusedOnRelease: true + }).map(({ hostId }) => hostId) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(Probe, { profiles: hosts })) + ) + await Promise.resolve() + }) + expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-c']) + + const rotatedHosts = hosts.map((host) => + host.id === 'host-d' ? { ...host, lastConnected: 5 } : host + ) + await act(async () => { + renderer?.update( + createElement(RpcClientProvider, null, createElement(Probe, { profiles: rotatedHosts })) + ) + await Promise.resolve() + }) + + expect(connectMock).toHaveBeenCalledTimes(4) + expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-d']) + expect(clients.get('host-a')?.closeMock).not.toHaveBeenCalled() + expect(clients.get('host-b')?.closeMock).not.toHaveBeenCalled() + expect(clients.get('host-c')?.closeMock).toHaveBeenCalledOnce() + expect(clients.get('host-d')?.closeMock).not.toHaveBeenCalled() + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('retains connect-all behavior when no startup subset is provided', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id, host2.id]) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledTimes(2) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('allows an excluded host to connect manually', async () => { + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST]) + + let reconnect: ((hostId: string) => Promise) | null = null + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id], { autoConnectHostIds: [] }) + reconnect = useForceReconnect() + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(connectMock).not.toHaveBeenCalled() + if (!reconnect) { + throw new Error('reconnect harness did not initialize') + } + await act(async () => { + await reconnect?.(HOST.id) + }) + expect(connectMock).toHaveBeenCalledOnce() + } finally { + restore() + act(() => renderer?.unmount()) + } + }) +}) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 8c00a11b2be..18ee0b7782a 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -14,9 +14,20 @@ import type { RpcClient } from './rpc-client' import { connectionLogStore } from './connection-log-buffer' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { HostClientOpenRegistry } from './host-client-open-registry' +import { decrementPendingAcquisition } from './host-client-acquisition-count' +import { + clientActivePath, + listHostClients, + notifyAllHostListeners, + notifyHostStateListeners, + primeHostProfiles, + subscribeAllHostListener, + subscribeHostStateListener, + type CloseEntryOptions +} from './host-client-context-state' import { loadHosts } from './host-store' import { openHostLogicalClient } from './host-logical-client' -import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState, HostProfile } from './types' type StoreEntry = { @@ -29,6 +40,8 @@ type StoreEntry = { export type RpcClientContextValue = { acquire: (hostId: string, host?: HostProfile) => RpcClient | null release: (hostId: string) => void + releaseAndCloseIfUnused: (hostId: string) => void + closeIfUnused: (hostId: string) => void forceReconnect: (hostId: string) => Promise closeHost: (hostId: string) => void getState: (hostId: string) => ConnectionState @@ -55,30 +68,27 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { // Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup. const pendingOpensRef = useRef(new HostClientOpenRegistry()) + const pendingAcquisitionsRef = useRef>(new Map()) // Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start. const primedHostsRef = useRef>(new Map()) - function notifyHostState(hostId: string, state: ConnectionState) { - const set = stateListenersRef.current.get(hostId) - if (!set) { - return - } - for (const listener of set) { - listener(state) - } - } + const notifyHostState = (hostId: string, state: ConnectionState) => + notifyHostStateListeners(stateListenersRef.current, hostId, state) + const notifyAllHosts = () => notifyAllHostListeners(allHostsListenersRef.current) - function notifyAllHosts() { - for (const listener of allHostsListenersRef.current) { - listener() - } - } - - const closeEntry = useCallback((hostId: string) => { - pendingOpensRef.current.cancel(hostId) - primedHostsRef.current.delete(hostId) + const closeEntry = useCallback((hostId: string, options: CloseEntryOptions) => { const entry = storeRef.current.get(hostId) + const acquisitionCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0 + pendingOpensRef.current.cancel(hostId) + if (options.preserveAcquisitions && acquisitionCount > 0) { + pendingAcquisitionsRef.current.set(hostId, acquisitionCount) + } else { + pendingAcquisitionsRef.current.delete(hostId) + } + if (options.forgetPrimedHost) { + primedHostsRef.current.delete(hostId) + } entry?.unsubState() storeRef.current.delete(hostId) entry?.client.close() @@ -86,6 +96,13 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { notifyAllHosts() }, []) + const closeHost = useCallback( + (hostId: string) => { + closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: true }) + }, + [closeEntry] + ) + const openEntry = useCallback(async (hostId: string): Promise => { const existing = pendingOpensRef.current.getActivePromise(hostId) if (existing) { @@ -151,9 +168,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const entry: StoreEntry = { client, state: client.getState(), - refCount: 0, + refCount: pendingAcquisitionsRef.current.get(hostId) ?? 0, unsubState } + pendingAcquisitionsRef.current.delete(hostId) storeRef.current.set(hostId, entry) notifyHostState(hostId, entry.state) notifyAllHosts() @@ -175,50 +193,83 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { existing.refCount += 1 return existing.client } + pendingAcquisitionsRef.current.set( + hostId, + (pendingAcquisitionsRef.current.get(hostId) ?? 0) + 1 + ) // Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes. - void openEntry(hostId).then((entry) => { - if (!entry) { - return - } - entry.refCount += 1 - }) + void openEntry(hostId) return null }, [openEntry] ) - const primeHosts = useCallback((hosts: HostProfile[]) => { - for (const host of hosts) { - primedHostsRef.current.set(host.id, host) - } - }, []) + const primeHosts = useCallback( + (hosts: HostProfile[]) => primeHostProfiles(primedHostsRef.current, hosts), + [] + ) // Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded. const release = useCallback((hostId: string) => { const entry = storeRef.current.get(hostId) - if (!entry) { + if (entry) { + entry.refCount = Math.max(0, entry.refCount - 1) return } - entry.refCount = Math.max(0, entry.refCount - 1) + decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) }, []) + const releaseAndCloseIfUnused = useCallback( + (hostId: string) => { + const entry = storeRef.current.get(hostId) + if (entry) { + entry.refCount = Math.max(0, entry.refCount - 1) + if (entry.refCount === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + return + } + if (decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + }, + [closeEntry] + ) + + const closeIfUnused = useCallback( + (hostId: string) => { + const entry = storeRef.current.get(hostId) + const pendingCount = pendingAcquisitionsRef.current.get(hostId) + const hasPendingOpen = pendingOpensRef.current.getActivePromise(hostId) !== null + if (!entry && pendingCount === undefined && !hasPendingOpen) { + return + } + if ((entry?.refCount ?? pendingCount ?? 0) === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + }, + [closeEntry] + ) + const forceReconnect = useCallback( async (hostId: string) => { const entry = storeRef.current.get(hostId) - // Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count. - const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0 - const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount) + // Why: ownership survives explicit close/re-pair while observers never become synthetic owners. + const savedRefCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0 if (entry) { entry.unsubState() entry.client.close() storeRef.current.delete(hostId) } + if (savedRefCount > 0) { + pendingAcquisitionsRef.current.set( + hostId, + Math.max(savedRefCount, pendingAcquisitionsRef.current.get(hostId) ?? 0) + ) + } // Why: Retry must read amber for the whole reopen, not grey-then-amber. notifyHostState(hostId, 'connecting') - const fresh = await openEntry(hostId) - if (fresh) { - fresh.refCount = savedRefCount - } + await openEntry(hostId) }, [openEntry] ) @@ -252,41 +303,17 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { }, []) const subscribeHostState = useCallback( - (hostId: string, listener: (state: ConnectionState) => void) => { - let set = stateListenersRef.current.get(hostId) - if (!set) { - set = new Set() - stateListenersRef.current.set(hostId, set) - } - set.add(listener) - return () => { - const s = stateListenersRef.current.get(hostId) - if (!s) { - return - } - s.delete(listener) - if (s.size === 0) { - stateListenersRef.current.delete(hostId) - } - } - }, + (hostId: string, listener: (state: ConnectionState) => void) => + subscribeHostStateListener(stateListenersRef.current, hostId, listener), [] ) - const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => { - const out: Array<{ hostId: string; client: RpcClient }> = [] - for (const [hostId, entry] of storeRef.current) { - out.push({ hostId, client: entry.client }) - } - return out - }, []) + const getAllClients = useCallback(() => listHostClients(storeRef.current), []) - const subscribeAllHosts = useCallback((listener: () => void) => { - allHostsListenersRef.current.add(listener) - return () => { - allHostsListenersRef.current.delete(listener) - } - }, []) + const subscribeAllHosts = useCallback( + (listener: () => void) => subscribeAllHostListener(allHostsListenersRef.current, listener), + [] + ) // Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -294,8 +321,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const store = storeRef.current return () => { pendingOpensRef.current.cancelAll() + pendingAcquisitionsRef.current.clear() for (const [hostId] of store) { - closeEntry(hostId) + closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: false }) } } }, []) @@ -313,8 +341,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { () => ({ acquire, release, + releaseAndCloseIfUnused, + closeIfUnused, forceReconnect, - closeHost: closeEntry, + closeHost, getState, getKnownState, getReconnectAttempt, @@ -328,8 +358,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { [ acquire, release, + releaseAndCloseIfUnused, + closeIfUnused, forceReconnect, - closeEntry, + closeHost, getState, getKnownState, getReconnectAttempt, @@ -437,13 +469,3 @@ export function usePrimeHosts(): (hosts: HostProfile[]) => void { const ctx = useRpcClientContext() return ctx.primeHosts } - -function clientActivePath(client: RpcClient | undefined): MobileConnectionPath { - const logical = client as Partial | undefined - if (typeof logical?.getActivePath !== 'function') { - return 'lan' - } - // Why: mid-migration the active path still names the session being replaced; the - // pending one is what the user is actually waiting on (F5). - return logical.getPendingPath?.() ?? logical.getActivePath() -} diff --git a/mobile/src/transport/home-host-auto-connect.test.ts b/mobile/src/transport/home-host-auto-connect.test.ts new file mode 100644 index 00000000000..ecf04934809 --- /dev/null +++ b/mobile/src/transport/home-host-auto-connect.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { HostProfile } from './types' +import { + HOME_AUTO_CONNECT_LIMIT, + resolveHomeHostConnectionState, + selectHomeAutoConnectHostIds +} from './home-host-auto-connect' + +function host(id: string, lastConnected: number, credentials = true): HostProfile { + return { + id, + name: id, + endpoint: `ws://${id}`, + deviceToken: credentials ? `token-${id}` : '', + publicKeyB64: credentials ? `key-${id}` : '', + lastConnected + } +} + +describe('home host auto-connect', () => { + it('limits startup connections to the most recently used credentialed hosts', () => { + const hosts = [ + host('old', 1), + host('newest', 5), + host('second', 4), + host('third', 3), + host('fourth', 2), + host('missing-credentials', 6, false) + ] + + expect(selectHomeAutoConnectHostIds(hosts)).toEqual(['newest', 'second', 'third']) + expect(selectHomeAutoConnectHostIds(hosts)).toHaveLength(HOME_AUTO_CONNECT_LIMIT) + }) + + it('does not mutate the host card order', () => { + const hosts = [host('old', 1), host('new', 2)] + + selectHomeAutoConnectHostIds(hosts) + + expect(hosts.map((item) => item.id)).toEqual(['old', 'new']) + }) + + it('only presents hosts in the startup subset as connecting before clients open', () => { + const autoConnectHostIds = ['recent'] + + expect(resolveHomeHostConnectionState('recent', undefined, autoConnectHostIds)).toBe( + 'connecting' + ) + expect(resolveHomeHostConnectionState('stale', undefined, autoConnectHostIds)).toBe( + 'disconnected' + ) + expect(resolveHomeHostConnectionState('stale', 'connected', autoConnectHostIds)).toBe( + 'connected' + ) + }) +}) diff --git a/mobile/src/transport/home-host-auto-connect.ts b/mobile/src/transport/home-host-auto-connect.ts new file mode 100644 index 00000000000..995d653a0df --- /dev/null +++ b/mobile/src/transport/home-host-auto-connect.ts @@ -0,0 +1,24 @@ +import type { ConnectionState, HostProfile } from './types' + +export const HOME_AUTO_CONNECT_LIMIT = 3 + +export function selectHomeAutoConnectHostIds( + hosts: readonly HostProfile[], + limit = HOME_AUTO_CONNECT_LIMIT +): string[] { + return [...hosts] + .filter((host) => host.deviceToken.length > 0 && host.publicKeyB64.length > 0) + .sort( + (left, right) => right.lastConnected - left.lastConnected || left.id.localeCompare(right.id) + ) + .slice(0, Math.max(0, limit)) + .map((host) => host.id) +} + +export function resolveHomeHostConnectionState( + hostId: string, + state: ConnectionState | undefined, + autoConnectHostIds: readonly string[] +): ConnectionState { + return state ?? (autoConnectHostIds.includes(hostId) ? 'connecting' : 'disconnected') +} diff --git a/mobile/src/transport/host-client-acquisition-count.ts b/mobile/src/transport/host-client-acquisition-count.ts new file mode 100644 index 00000000000..1a1123e5c8b --- /dev/null +++ b/mobile/src/transport/host-client-acquisition-count.ts @@ -0,0 +1,9 @@ +export function decrementPendingAcquisition(pending: Map, hostId: string): number { + const next = Math.max(0, (pending.get(hostId) ?? 0) - 1) + if (next === 0) { + pending.delete(hostId) + } else { + pending.set(hostId, next) + } + return next +} diff --git a/mobile/src/transport/host-client-context-state.ts b/mobile/src/transport/host-client-context-state.ts new file mode 100644 index 00000000000..e0f7243baa8 --- /dev/null +++ b/mobile/src/transport/host-client-context-state.ts @@ -0,0 +1,72 @@ +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile } from './types' + +export type CloseEntryOptions = { + forgetPrimedHost: boolean + preserveAcquisitions: boolean +} + +export function notifyHostStateListeners( + listeners: Map void>>, + hostId: string, + state: ConnectionState +): void { + for (const listener of listeners.get(hostId) ?? []) { + listener(state) + } +} + +export function notifyAllHostListeners(listeners: Set<() => void>): void { + for (const listener of listeners) { + listener() + } +} + +export function subscribeHostStateListener( + listeners: Map void>>, + hostId: string, + listener: (state: ConnectionState) => void +): () => void { + let hostListeners = listeners.get(hostId) + if (!hostListeners) { + hostListeners = new Set() + listeners.set(hostId, hostListeners) + } + hostListeners.add(listener) + return () => { + hostListeners.delete(listener) + if (hostListeners.size === 0) { + listeners.delete(hostId) + } + } +} + +export function subscribeAllHostListener( + listeners: Set<() => void>, + listener: () => void +): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function listHostClients( + entries: ReadonlyMap +): { hostId: string; client: RpcClient }[] { + return [...entries].map(([hostId, entry]) => ({ hostId, client: entry.client })) +} + +export function primeHostProfiles(cache: Map, hosts: HostProfile[]): void { + for (const host of hosts) { + cache.set(host.id, host) + } +} + +export function clientActivePath(client: RpcClient | undefined): MobileConnectionPath { + const logical = client as Partial | undefined + if (typeof logical?.getActivePath !== 'function') { + return 'lan' + } + // Why: during migration the pending path is what the user is waiting on. + return logical.getPendingPath?.() ?? logical.getActivePath() +} diff --git a/mobile/src/transport/settings-host-client-connections.ts b/mobile/src/transport/settings-host-client-connections.ts new file mode 100644 index 00000000000..b2341471d2b --- /dev/null +++ b/mobile/src/transport/settings-host-client-connections.ts @@ -0,0 +1,19 @@ +import { useCallback, useState } from 'react' +import { useFocusEffect } from 'expo-router' +import { useAllHostClients } from './use-all-host-clients' + +export function useFocusedSettingsHostClients(hostIds: string[]) { + const [focused, setFocused] = useState(false) + + useFocusEffect( + useCallback(() => { + setFocused(true) + return () => setFocused(false) + }, []) + ) + + const clients = useAllHostClients(focused ? hostIds : [], { + closeUnusedOnRelease: true + }) + return { clients, focused } +} diff --git a/mobile/src/transport/settings-host-client-lifecycle.test.ts b/mobile/src/transport/settings-host-client-lifecycle.test.ts new file mode 100644 index 00000000000..7a23c86ac71 --- /dev/null +++ b/mobile/src/transport/settings-host-client-lifecycle.test.ts @@ -0,0 +1,776 @@ +import { createElement, Fragment, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAllHostClients } from './use-all-host-clients' +import { useFocusedSettingsHostClients } from './settings-host-client-connections' +import { + RpcClientProvider, + useHostClient, + usePrimeHosts, + useRpcClientContext, + type RpcClientContextValue +} from './client-context' +import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, HostProfile } from './types' + +const connectMock = vi.fn() +const loadHostsMock = vi.fn() +const routeFocus = vi.hoisted(() => ({ + effect: null as null | (() => void | (() => void)) +})) + +vi.mock('expo-router', () => ({ + useFocusEffect: (effect: () => void | (() => void)) => { + routeFocus.effect = effect + } +})) + +vi.mock('./host-logical-client', () => ({ + openHostLogicalClient: (...args: unknown[]) => connectMock(...args) +})) +vi.mock('./host-store', () => ({ + loadHosts: () => loadHostsMock() +})) +vi.mock('./connection-revival-triggers', () => ({ + subscribeConnectionRevivalTriggers: () => () => {} +})) + +type FakeClient = RpcClient & { + closeMock: ReturnType +} + +function makeFakeClient(initialState: ConnectionState): FakeClient { + const listeners = new Set<(state: ConnectionState) => void>() + const closeMock = vi.fn() + return { + sendRequest: vi.fn(), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => initialState, + getReconnectAttempt: () => (initialState === 'reconnecting' ? 4 : 0), + getLastConnectedAt: () => null, + onStateChange: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + notifyForeground: vi.fn(), + close: closeMock, + closeMock + } as FakeClient +} + +function host(id: string, lastConnected: number, extra: Partial = {}): HostProfile { + return { + id, + name: id, + endpoint: `ws://${id}.internal:8787`, + deviceToken: `token-${id}`, + publicKeyB64: `key-${id}`, + lastConnected, + ...extra + } +} + +const HOSTS = [ + host('direct-recent', 50), + host('relay-recent', 40, { relayHostId: 'AbCdEf0123_-xyZ9' }), + host('ssh-provider-recent', 30), + host('folder-workspace-host', 20), + host('offline-host', 10) +] +const HOST_IDS = HOSTS.map((profile) => profile.id) +const HOME_HOST_IDS = selectHomeAutoConnectHostIds(HOSTS) + +let context: RpcClientContextValue | null = null + +function ContextProbe(): null { + context = useRpcClientContext() + return null +} + +function HomeProbe(): null { + useAllHostClients(HOST_IDS, { + autoConnectHostIds: HOME_HOST_IDS, + closeUnusedOnRelease: true + }) + const primeHosts = usePrimeHosts() + useEffect(() => { + primeHosts(HOSTS) + }, [primeHosts]) + return null +} + +function SettingsProbe(): null { + useAllHostClients(HOST_IDS, { + closeUnusedOnRelease: true + }) + return null +} + +function FocusedSettingsProbe(): null { + useFocusedSettingsHostClients(HOST_IDS) + return null +} + +function DynamicSettingsProbe({ hostIds }: { hostIds: string[] }): null { + useAllHostClients(hostIds, { closeUnusedOnRelease: true }) + return null +} + +function DetailProbe({ hostId }: { hostId: string }): null { + useHostClient(hostId) + return null +} + +type Screen = 'empty' | 'home' | 'settings' + +function TestApp({ + screen, + detailHostId +}: { + screen: Screen + detailHostId?: string +}): React.JSX.Element { + const screenProbe = + screen === 'home' + ? createElement(HomeProbe) + : screen === 'settings' + ? createElement(SettingsProbe) + : null + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + detailHostId ? createElement(DetailProbe, { hostId: detailHostId }) : null, + screenProbe + ) + ) +} + +function suppressRendererWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +async function renderScreen(screen: Screen, detailHostId?: string): Promise { + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(TestApp, { screen, detailHostId })) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('settings lifecycle harness did not render') + } + return renderer +} + +async function navigate( + renderer: ReactTestRenderer, + screen: Screen, + detailHostId?: string +): Promise { + await act(async () => { + renderer.update(createElement(TestApp, { screen, detailHostId })) + await Promise.resolve() + await Promise.resolve() + }) +} + +function activeHostIds(): string[] { + if (!context) { + throw new Error('client context was not captured') + } + return context + .getAllClients() + .map(({ hostId }) => hostId) + .sort() +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + context = null + routeFocus.effect = null + connectMock.mockReset() + loadHostsMock.mockReset() +}) + +describe('settings host client lifecycle', () => { + it('closes settings-only clients while a mounted Home owner keeps its bounded set', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient(profile.id === 'offline-host' ? 'reconnecting' : 'connected') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + function NavigationStack({ settingsVisible }: { settingsVisible: boolean }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + settingsVisible ? createElement(SettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(NavigationStack, { settingsVisible: false })) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('navigation stack harness did not render') + } + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + expect(connectMock).toHaveBeenCalledTimes(3) + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: true })) + await Promise.resolve() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + expect(connectMock).toHaveBeenCalledTimes(HOSTS.length) + expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length) + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: false })) + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + for (const hostId of HOME_HOST_IDS) { + expect(clients.get(hostId)).toHaveLength(1) + expect(clients.get(hostId)?.[0]?.closeMock).not.toHaveBeenCalled() + } + for (const hostId of ['folder-workspace-host', 'offline-host']) { + expect(clients.get(hostId)?.[0]?.closeMock).toHaveBeenCalledOnce() + } + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: true })) + await Promise.resolve() + await Promise.resolve() + }) + expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length) + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: false })) + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + for (const hostId of HOME_HOST_IDS) { + expect(clients.get(hostId)).toHaveLength(1) + } + for (const hostId of ['folder-workspace-host', 'offline-host']) { + expect(clients.get(hostId)).toHaveLength(2) + expect(clients.get(hostId)?.every((client) => client.closeMock.mock.calls.length === 1)).toBe( + true + ) + } + + act(() => renderer.unmount()) + }) + + it('leaves no zero-reference clients after repeated settings navigation', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const renderer = await renderScreen('settings') + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + + await navigate(renderer, 'empty') + expect(activeHostIds()).toEqual([]) + for (const hostClients of clients.values()) { + expect(hostClients).toHaveLength(1) + expect(hostClients[0]?.closeMock).toHaveBeenCalledOnce() + } + + await navigate(renderer, 'settings') + await navigate(renderer, 'empty') + expect(activeHostIds()).toEqual([]) + expect(connectMock).toHaveBeenCalledTimes(HOSTS.length * 2) + for (const hostClients of clients.values()) { + expect(hostClients).toHaveLength(2) + expect(hostClients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true) + } + + act(() => renderer.unmount()) + }) + + it('reconciles host-list changes without restarting retained or shared clients', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const retainedHostId = 'direct-recent' + const sharedHostId = 'relay-recent' + const removedHostId = 'folder-workspace-host' + const addedHostId = 'offline-host' + function HostListApp({ settingsHostIds }: { settingsHostIds: string[] }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(DetailProbe, { hostId: sharedHostId }), + createElement(DynamicSettingsProbe, { hostIds: settingsHostIds }) + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(HostListApp, { + settingsHostIds: [retainedHostId, sharedHostId, removedHostId] + }) + ) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('host-list lifecycle harness did not render') + } + + await act(async () => { + renderer?.update( + createElement(HostListApp, { + settingsHostIds: [retainedHostId, addedHostId] + }) + ) + await Promise.resolve() + await Promise.resolve() + }) + + expect(connectMock).toHaveBeenCalledTimes(4) + expect(activeHostIds()).toEqual([addedHostId, retainedHostId, sharedHostId].sort()) + expect(clients.get(retainedHostId)?.closeMock).not.toHaveBeenCalled() + expect(clients.get(sharedHostId)?.closeMock).not.toHaveBeenCalled() + expect(clients.get(removedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(addedHostId)?.closeMock).not.toHaveBeenCalled() + + act(() => renderer.unmount()) + expect(clients.get(retainedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(sharedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(addedHostId)?.closeMock).toHaveBeenCalledOnce() + }) + + it('does not close a settings client still held by an active consumer', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('connected') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const detailHostId = 'folder-workspace-host' + const renderer = await renderScreen('settings', detailHostId) + await navigate(renderer, 'home', detailHostId) + + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + expect(clients.get(detailHostId)?.closeMock).not.toHaveBeenCalled() + + act(() => renderer.unmount()) + expect(clients.get(detailHostId)?.closeMock).toHaveBeenCalledOnce() + }) + + it('keeps a reconnect alive when another consumer remains after settings leaves', async () => { + const retryHost = host('ssh-retry-host', 1) + let resolveInitial: ((hosts: HostProfile[]) => void) | null = null + let resolveRetry: ((hosts: HostProfile[]) => void) | null = null + const initialLookup = new Promise((resolve) => { + resolveInitial = resolve + }) + const retryLookup = new Promise((resolve) => { + resolveRetry = resolve + }) + loadHostsMock.mockReturnValueOnce(initialLookup).mockReturnValueOnce(retryLookup) + const initialClient = makeFakeClient('connected') + const retryClient = makeFakeClient('reconnecting') + connectMock.mockReturnValueOnce(initialClient).mockReturnValueOnce(retryClient) + + function RetrySettingsProbe(): null { + useAllHostClients([retryHost.id], { + closeUnusedOnRelease: true + }) + return null + } + function RetryApp({ settingsVisible }: { settingsVisible: boolean }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(DetailProbe, { hostId: retryHost.id }), + settingsVisible ? createElement(RetrySettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(RetryApp, { settingsVisible: true })) + }) + } finally { + restore() + } + if (!renderer || !resolveInitial || !resolveRetry) { + throw new Error('retry lifecycle harness did not initialize') + } + await act(async () => { + resolveInitial?.([retryHost]) + await initialLookup + }) + if (!context) { + throw new Error('client context was not captured') + } + + const reconnect = context.forceReconnect(retryHost.id) + expect(initialClient.closeMock).toHaveBeenCalledOnce() + act(() => renderer?.update(createElement(RetryApp, { settingsVisible: false }))) + await act(async () => { + resolveRetry?.([retryHost]) + await retryLookup + await reconnect + }) + + expect(activeHostIds()).toEqual([retryHost.id]) + expect(retryClient.closeMock).not.toHaveBeenCalled() + act(() => renderer?.unmount()) + expect(retryClient.closeMock).toHaveBeenCalledOnce() + }) + + it('cancels a released open without cancelling a rapid replacement acquisition', async () => { + const settingsOnlyHost = host('settings-only-offline', 0, { + deviceToken: '', + publicKeyB64: '' + }) + let resolveFirst: ((hosts: HostProfile[]) => void) | null = null + let resolveSecond: ((hosts: HostProfile[]) => void) | null = null + const firstLookup = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondLookup = new Promise((resolve) => { + resolveSecond = resolve + }) + loadHostsMock.mockReturnValueOnce(firstLookup).mockReturnValueOnce(secondLookup) + const client = makeFakeClient('reconnecting') + connectMock.mockReturnValue(client) + + function PendingSettingsProbe(): null { + useAllHostClients([settingsOnlyHost.id], { + closeUnusedOnRelease: true + }) + return null + } + function PendingApp({ visible }: { visible: boolean }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + visible ? createElement(PendingSettingsProbe) : null + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(PendingApp, { visible: true })) + }) + } finally { + restore() + } + if (!renderer || !resolveFirst || !resolveSecond) { + throw new Error('pending settings harness did not initialize') + } + + act(() => renderer?.update(createElement(PendingApp, { visible: false }))) + act(() => renderer?.update(createElement(PendingApp, { visible: true }))) + await act(async () => { + resolveFirst?.([settingsOnlyHost]) + await firstLookup + }) + expect(connectMock).not.toHaveBeenCalled() + + await act(async () => { + resolveSecond?.([settingsOnlyHost]) + await secondLookup + }) + expect(connectMock).toHaveBeenCalledOnce() + + act(() => renderer?.update(createElement(PendingApp, { visible: false }))) + expect(client.closeMock).toHaveBeenCalledOnce() + act(() => renderer?.unmount()) + }) + + it('preserves Home ownership when re-pairing replaces a selected host client', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('connected') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + const replacedHostId = HOME_HOST_IDS[0]! + + function ReplacementApp({ + detailVisible, + settingsVisible + }: { + detailVisible: boolean + settingsVisible: boolean + }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + detailVisible ? createElement(DetailProbe, { hostId: replacedHostId }) : null, + settingsVisible ? createElement(SettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) + ) + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer || !context) { + throw new Error('replacement lifecycle harness did not initialize') + } + + const originalClient = clients.get(replacedHostId)?.[0] + act(() => context?.closeHost(replacedHostId)) + expect(originalClient?.closeMock).toHaveBeenCalledOnce() + + await act(async () => { + renderer?.update( + createElement(ReplacementApp, { detailVisible: true, settingsVisible: false }) + ) + await Promise.resolve() + await Promise.resolve() + }) + const replacementClient = clients.get(replacedHostId)?.[1] + expect(replacementClient).toBeDefined() + + await act(async () => { + renderer?.update( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: true }) + ) + await Promise.resolve() + await Promise.resolve() + }) + act(() => + renderer?.update( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) + ) + ) + + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + expect(replacementClient?.closeMock).not.toHaveBeenCalled() + act(() => renderer?.unmount()) + expect(replacementClient?.closeMock).toHaveBeenCalledOnce() + }) + + it('releases a manual host after Home demotes or stops tracking it', async () => { + const manualHost = host('manual-relay-host', 1, { relayHostId: 'AbCdEf0123_-xyZ9' }) + const clients: FakeClient[] = [] + connectMock.mockImplementation(() => { + const client = makeFakeClient('reconnecting') + clients.push(client) + return client + }) + loadHostsMock.mockResolvedValue([manualHost]) + + function ManualHomeProbe({ selected }: { selected: boolean }): null { + useAllHostClients([manualHost.id], { + autoConnectHostIds: selected ? [manualHost.id] : [], + closeUnusedOnRelease: true + }) + return null + } + function ManualApp({ + selected, + homeVisible = true + }: { + selected: boolean + homeVisible?: boolean + }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + homeVisible ? createElement(ManualHomeProbe, { selected }) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(ManualApp, { selected: false })) + }) + } finally { + restore() + } + if (!renderer || !context) { + throw new Error('manual connection harness did not initialize') + } + await act(async () => { + await context?.forceReconnect(manualHost.id) + }) + expect(activeHostIds()).toEqual([manualHost.id]) + + await act(async () => { + renderer?.update(createElement(ManualApp, { selected: true })) + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([manualHost.id]) + + await act(async () => { + renderer?.update(createElement(ManualApp, { selected: false })) + await Promise.resolve() + }) + + expect(activeHostIds()).toEqual([]) + expect(clients[0]?.closeMock).toHaveBeenCalledOnce() + + await act(async () => { + await context?.forceReconnect(manualHost.id) + }) + expect(activeHostIds()).toEqual([manualHost.id]) + expect(clients).toHaveLength(2) + + act(() => renderer?.update(createElement(ManualApp, { selected: false, homeVisible: false }))) + expect(activeHostIds()).toEqual([]) + expect(clients[1]?.closeMock).toHaveBeenCalledOnce() + + act(() => renderer?.unmount()) + expect(clients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true) + }) + + it('releases settings-only clients on blur while shared owners stay mounted', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + const detailHostId = 'folder-workspace-host' + + function FocusStack(): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + createElement(DetailProbe, { hostId: detailHostId }), + createElement(FocusedSettingsProbe) + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(FocusStack)) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer || !routeFocus.effect) { + throw new Error('settings focus harness did not initialize') + } + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + + let blur: (() => void) | undefined + await act(async () => { + const cleanup = routeFocus.effect?.() + if (typeof cleanup === 'function') { + blur = cleanup + } + await Promise.resolve() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + + await act(async () => { + blur?.() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + for (const hostId of [...HOME_HOST_IDS, detailHostId]) { + expect(clients.get(hostId)?.closeMock).not.toHaveBeenCalled() + } + expect(clients.get('offline-host')?.closeMock).toHaveBeenCalledOnce() + + act(() => renderer?.unmount()) + }) +}) diff --git a/mobile/src/transport/use-all-host-clients.ts b/mobile/src/transport/use-all-host-clients.ts index a1e429b45b0..1000916113b 100644 --- a/mobile/src/transport/use-all-host-clients.ts +++ b/mobile/src/transport/use-all-host-clients.ts @@ -1,56 +1,126 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState } from './types' import { useRpcClientContext } from './client-context' -// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts. -export function useAllHostClients(hostIds: string[]) { +type UseAllHostClientsOptions = { + autoConnectHostIds?: readonly string[] + closeUnusedOnRelease?: boolean +} + +export function useAllHostClients(hostIds: string[], options?: UseAllHostClientsOptions) { const ctx = useRpcClientContext() - // Stable key so we don't tear down on every render of the array. - const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) + const autoConnectHostIds = options?.autoConnectHostIds ?? hostIds + const closeUnusedOnRelease = options?.closeUnusedOnRelease ?? false + const key = useMemo( + () => + [ + [...hostIds].sort().join(','), + [...autoConnectHostIds].sort().join(','), + closeUnusedOnRelease ? 'close' : 'keep' + ].join('|'), + [autoConnectHostIds, closeUnusedOnRelease, hostIds] + ) const [tick, setTick] = useState(0) + const acquiredHostIdsRef = useRef>(new Set()) + const hostUnsubscribesRef = useRef void>>(new Map()) + const closeUnusedRef = useRef(closeUnusedOnRelease) useEffect(() => { - if (hostIds.length === 0) { - return - } - for (const id of hostIds) { - ctx.acquire(id) - } - const unsubs: (() => void)[] = [] - for (const id of hostIds) { - unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1))) - } - unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1))) + closeUnusedRef.current = closeUnusedOnRelease + }, [closeUnusedOnRelease]) + + useEffect(() => { + const unsubscribeAllHosts = ctx.subscribeAllHosts(() => setTick((value) => value + 1)) return () => { - for (const u of unsubs) { - u() + unsubscribeAllHosts() + const trackedHostIds = [...hostUnsubscribesRef.current.keys()] + const acquiredHostIds = new Set(acquiredHostIdsRef.current) + for (const unsubscribe of hostUnsubscribesRef.current.values()) { + unsubscribe() } - for (const id of hostIds) { - ctx.release(id) + hostUnsubscribesRef.current.clear() + for (const id of acquiredHostIds) { + if (closeUnusedRef.current) { + ctx.releaseAndCloseIfUnused(id) + } else { + ctx.release(id) + } + } + if (closeUnusedRef.current) { + for (const id of trackedHostIds) { + if (!acquiredHostIds.has(id)) { + ctx.closeIfUnused(id) + } + } + } + acquiredHostIdsRef.current.clear() + } + }, [ctx]) + + useEffect(() => { + const trackedHostIds = new Set(hostIds) + const nextAcquiredHostIds = new Set(autoConnectHostIds.filter((id) => trackedHostIds.has(id))) + const removedTrackedHostIds: string[] = [] + + for (const [id, unsubscribe] of hostUnsubscribesRef.current) { + if (!trackedHostIds.has(id)) { + unsubscribe() + hostUnsubscribesRef.current.delete(id) + removedTrackedHostIds.push(id) } } - }, [key]) + for (const id of trackedHostIds) { + if (!hostUnsubscribesRef.current.has(id)) { + hostUnsubscribesRef.current.set( + id, + ctx.subscribeHostState(id, () => setTick((value) => value + 1)) + ) + } + } + + for (const id of acquiredHostIdsRef.current) { + if (!nextAcquiredHostIds.has(id)) { + if (closeUnusedOnRelease) { + ctx.releaseAndCloseIfUnused(id) + } else { + ctx.release(id) + } + } + } + for (const id of nextAcquiredHostIds) { + if (!acquiredHostIdsRef.current.has(id)) { + ctx.acquire(id) + } + } + if (closeUnusedOnRelease) { + for (const id of removedTrackedHostIds) { + ctx.closeIfUnused(id) + } + for (const id of trackedHostIds) { + if (!nextAcquiredHostIds.has(id)) { + ctx.closeIfUnused(id) + } + } + } + acquiredHostIdsRef.current = nextAcquiredHostIds + }, [ctx, key]) return useMemo(() => { - const out: { + const clientsByHostId = new Map( + ctx.getAllClients().map((entry) => [entry.hostId, entry.client]) + ) + return hostIds.flatMap<{ hostId: string client: RpcClient state: ConnectionState path: MobileConnectionPath - }[] = [] - for (const id of hostIds) { - const all = ctx.getAllClients().find((entry) => entry.hostId === id) - if (all) { - out.push({ - hostId: id, - client: all.client, - state: ctx.getState(id), - path: ctx.getActivePath(id) - }) - } - } - return out - }, [key, tick]) + }>((hostId) => { + const client = clientsByHostId.get(hostId) + return client + ? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }] + : [] + }) + }, [ctx, hostIds, tick]) } From c2da0e47f94ca79948f881f2d1c1a8be8a7e77ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:14:18 -0700 Subject: [PATCH 32/46] fix(computer): deliver macOS coordinate clicks via the HID event tap (STA-3433) (#12839) Mouse events posted with CGEventPostToPid reach the target app with no window association, so AppKit never routes the press to a view: hover states fire but the control is never activated, and the mouseUp is dropped outright when posted back-to-back. Post click events to the HID event tap instead (as keyboard synthesis already does), pace them, and stamp mouseEventClickState so multi-clicks register. Synthetic clicks now also report verification unverified/synthetic_input from the helper itself, matching the other synthetic actions. --- .../computer-use-modifier-safety.test.mjs | 5 +- .../Sources/OrcaComputerUseMacOS/main.swift | 44 ++++++++--- .../SyntheticMouseClickDelivery.swift | 40 ++++++++++ .../SyntheticMouseClickDeliveryTests.swift | 43 ++++++++++ tests/e2e/computer-mac.e2e.ts | 78 +++++++++++++++++++ 5 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift create mode 100644 native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift diff --git a/config/scripts/computer-use-modifier-safety.test.mjs b/config/scripts/computer-use-modifier-safety.test.mjs index f39015796a9..32fdd94e938 100644 --- a/config/scripts/computer-use-modifier-safety.test.mjs +++ b/config/scripts/computer-use-modifier-safety.test.mjs @@ -28,7 +28,10 @@ describe('computer-use modifier safety', () => { ) expect(mouseInput).toContain('event.flags = flags') - expect(clickInput.match(/flags: flags/gu)).toHaveLength(3) + // Every click event flows through the shared delivery plan and carries + // the modifier flags on the mouse event itself. + expect(clickInput).toContain('SyntheticMouseClickDelivery.steps(clickCount: count)') + expect(clickInput).toContain('event.flags = flags') expect(clickInput).not.toContain('down: true') }) diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 50ecae7d024..7236f7bab2c 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -740,25 +740,30 @@ final class Provider { } if let point = center(record.localFrame, in: snapshot.windowBounds) { try Input.click( - pid: snapshot.app.pid, at: point, button: mouseButton(button), count: count, modifiers: modifiers ) - return actionMetadata(path: "synthetic", fallbackReason: "actionUnsupported") + return actionMetadata( + path: "synthetic", + fallbackReason: "actionUnsupported", + verification: unverifiedAction(reason: "synthetic_input") + ) } throw ProviderError.coded("element_not_clickable", "element \(record.index) has no clickable frame") } let point = try coordinatePoint(params: params, xKey: "x", yKey: "y", snapshot: snapshot) try Input.click( - pid: snapshot.app.pid, at: point, button: mouseButton(button), count: count, modifiers: modifiers ) - return actionMetadata(path: "synthetic") + return actionMetadata( + path: "synthetic", + verification: unverifiedAction(reason: "synthetic_input") + ) } private func performClickAction(record: ElementRecord, mouseButton: String) throws -> String? { @@ -2286,7 +2291,6 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? { private enum Input { static func click( - pid: pid_t, at point: CGPoint, button: MouseButton, count: Int, @@ -2298,10 +2302,32 @@ private enum Input { let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in result.insert(modifier.flag) } - for _ in 0.. 0 { + event.setIntegerValueField(.mouseEventClickState, value: clickState) + } + event.post(tap: .cghidEventTap) + usleep(SyntheticMouseClickDelivery.interEventPauseMicroseconds) } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift new file mode 100644 index 00000000000..d61e56af35c --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift @@ -0,0 +1,40 @@ +/// Event plan for synthetic mouse clicks (STA-3433). +/// +/// Clicks must be posted to the HID event tap, not `CGEventPostToPid`: +/// pid-targeted mouse events reach the app with no window association, so +/// AppKit never routes the press to a view (hover fires, activation never +/// happens). The window server also drops a mouseUp posted back-to-back +/// with its mouseDown, so consecutive events need a pause between them. +public enum SyntheticMouseClickDelivery { + public enum Step: Equatable { + case move + case buttonDown(pressIndex: Int) + case buttonUp(pressIndex: Int) + } + + /// Pause after posting each event; unpaced posts race the window + /// server's routing and the mouseUp is silently dropped. + public static let interEventPauseMicroseconds: UInt32 = 50_000 + + /// One move, then a paired down/up per press. `pressIndex` becomes the + /// event's click state so repeated presses register as double/triple + /// clicks instead of independent single clicks. + public static func steps(clickCount: Int) -> [Step] { + var steps: [Step] = [.move] + for press in 1...max(clickCount, 1) { + steps.append(.buttonDown(pressIndex: press)) + steps.append(.buttonUp(pressIndex: press)) + } + return steps + } + + /// Click state field value for a step; 0 leaves the field unset. + public static func clickState(for step: Step) -> Int64 { + switch step { + case .move: + return 0 + case let .buttonDown(pressIndex), let .buttonUp(pressIndex): + return Int64(pressIndex) + } + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift new file mode 100644 index 00000000000..d80a5e9e8ee --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import OrcaComputerUseMacOSCore + +final class SyntheticMouseClickDeliveryTests: XCTestCase { + func testSingleClickPlanPairsDownAndUpAfterMove() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: 1), + [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)] + ) + } + + func testMultiClickPlanNumbersEachPressForClickState() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: 2), + [ + .move, + .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1), + .buttonDown(pressIndex: 2), .buttonUp(pressIndex: 2), + ] + ) + } + + func testNonPositiveClickCountStillDeliversOnePress() { + for count in [0, -3] { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: count), + [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)] + ) + } + } + + func testClickStateMatchesPressIndexAndSkipsMove() { + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .move), 0) + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .buttonDown(pressIndex: 1)), 1) + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .buttonUp(pressIndex: 2)), 2) + } + + func testInterEventPauseIsNonZero() { + // Unpaced posts race the window server and the mouseUp is dropped, + // turning the click into a hover-only no-op (STA-3433). + XCTAssertGreaterThan(SyntheticMouseClickDelivery.interEventPauseMicroseconds, 0) + } +} diff --git a/tests/e2e/computer-mac.e2e.ts b/tests/e2e/computer-mac.e2e.ts index 6e6c0a60873..6aa162cee40 100644 --- a/tests/e2e/computer-mac.e2e.ts +++ b/tests/e2e/computer-mac.e2e.ts @@ -71,6 +71,84 @@ describe.skipIf(!isMac || !e2eOptIn)('computer-use macOS e2e (TextEdit)', () => expect(after.result.snapshot.treeText).toContain(marker) }) + test('coordinate double-click activates a control, not just hover (STA-3433)', async () => { + // Ten identical short lines: any first-lines click hits a word, and the + // whole document stays inside the treeText value preview after editing. + const filler = Array(10).fill('wordword').join('\n') + await runOrcaCli([ + 'computer', + 'hotkey', + '--app', + 'TextEdit', + '--key', + 'CmdOrCtrl+A', + '--no-screenshot' + ]) + await runOrcaCli([ + 'computer', + 'paste-text', + '--app', + 'TextEdit', + '--text', + filler, + '--no-screenshot' + ]) + + // Word-select via coordinates: 40px in, 70px down lands inside a + // "wordword" on one of the first lines whether or not the ruler is shown. + const clicked = parseJsonOutput<{ result: ComputerActionResult }>( + ( + await runOrcaCli([ + 'computer', + 'click', + '--app', + 'TextEdit', + '--x', + '40', + '--y', + '70', + '--click-count', + '2', + '--no-screenshot', + '--json' + ]) + ).stdout + ) + expect(clicked.result.action?.path).toBe('synthetic') + expect(clicked.result.action?.verification).toMatchObject({ + state: 'unverified', + reason: 'synthetic_input' + }) + + const marker = `zz${Date.now()}zz` + await runOrcaCli([ + 'computer', + 'type-text', + '--app', + 'TextEdit', + '--text', + marker, + '--no-screenshot' + ]) + + const after = parseJsonOutput<{ result: ComputerSnapshotResult }>( + ( + await runOrcaCli([ + 'computer', + 'get-app-state', + '--app', + 'TextEdit', + '--no-screenshot', + '--json' + ]) + ).stdout + ) + // The double-click selected a mid-document word, so the typed marker must + // be followed by more filler. A dropped press leaves the caret at the end + // of the document and the marker only ever appends (the STA-3433 no-op). + expect(after.result.snapshot.treeText).toMatch(new RegExp(`${marker}\\s+wordword`)) + }) + test('paste-text and hotkey verify TextEdit text replacement', async () => { const first = parseJsonOutput<{ result: ComputerActionResult }>( ( From 215604f45819d524a0f25c1a02b6a100cce1a46f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:25:01 -0700 Subject: [PATCH 33/46] fix(tasks): hold dialog-confirmed issue state over stale Tasks list refetches (STA-3343) (#12830) * fix(tasks): hold dialog-confirmed issue state over stale list refetches (STA-3343) Closing an issue from GitHubItemDialog patched workItemsCache directly with no mutation-registry record, so a search-lagged Tasks refetch (GitHub search index eventual consistency + gh's ~120s URL cache) silently reverted the row to Open. Record the confirmed state as registry authority (same mechanism the list-row mutations use) so list fetch paths re-assert it until search catches up; quiet adopt still releases it on match, so external reverts win once the index is fresh. Covers issue close/reopen, PR close/reopen, and PR merge in the dialog. * fix(tasks): preserve newer state authority on rollback --- .../src/components/GitHubItemDialog.tsx | 37 +++- ...github-item-dialog-source-boundary.test.ts | 19 ++ ...page-github-dialog-state-authority.test.ts | 187 ++++++++++++++++++ ...task-page-github-dialog-state-authority.ts | 59 ++++++ 4 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/task-page-github-dialog-state-authority.test.ts create mode 100644 src/renderer/src/components/task-page-github-dialog-state-authority.ts diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 919b908f375..0e50c3bbc50 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -210,6 +210,7 @@ import { validateTaskPageGitHubDuplicateTarget, type TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' +import { assertTaskPageGitHubDialogStateAuthority } from '@/components/task-page-github-dialog-state-authority' import { sortChecksBySeverity } from '../../../shared/pr-check-severity-order' import { getCheckConclusion, @@ -2834,6 +2835,14 @@ function PRActionsPanel({ } const previousState = localState setStatePending(true) + // Why: without registry authority a search-lagged Tasks refetch silently + // reverts this row to its pre-mutation state (STA-3343). + const authority = assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: nextState, + sourceContext + }) applyStatePatch(nextState) try { await runPullRequestStateUpdate({ @@ -2853,7 +2862,9 @@ function PRActionsPanel({ ) onMutated() } catch (err) { - applyStatePatch(previousState) + if (authority.revert()) { + applyStatePatch(previousState) + } toast.error( err instanceof Error ? err.message @@ -2913,6 +2924,13 @@ function PRActionsPanel({ toast.error(result.error) return } + // Why: merge is confirmed here; hold 'merged' against search-lagged refetches. + assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: 'merged', + sourceContext + }) applyStatePatch('merged') if (mergeTarget.kind === 'environment') { notifyWorkItemDetailsMutation( @@ -4175,6 +4193,9 @@ function GHEditSection({ return } const prevState = localState + // Why: without registry authority a search-lagged Tasks refetch silently + // reverts this row to its pre-mutation state (STA-3343). + let authority: { revert: () => boolean } | null = null run('state', { mutate: () => runIssueUpdate({ @@ -4189,14 +4210,22 @@ function GHEditSection({ : { state: newState } }), onOptimistic: () => { + authority = assertTaskPageGitHubDialogStateAuthority({ + repoId: item.repoId, + itemId: item.id, + state: newState, + sourceContext + }) onStateChange(newState) patchWorkItem(item.id, { state: newState }, item.repoId, { sourceContext }) patchProjectRowIfNeeded({ state: newState }) }, onRevert: () => { - onStateChange(prevState) - patchWorkItem(item.id, { state: prevState }, item.repoId, { sourceContext }) - patchProjectRowIfNeeded({ state: prevState }) + if (authority?.revert()) { + onStateChange(prevState) + patchWorkItem(item.id, { state: prevState }, item.repoId, { sourceContext }) + patchProjectRowIfNeeded({ state: prevState }) + } }, onSuccess: () => { useAppStore.getState().recordFeatureInteraction('github-tasks') diff --git a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts index 4aa11354059..a773910d332 100644 --- a/src/renderer/src/components/github-item-dialog-source-boundary.test.ts +++ b/src/renderer/src/components/github-item-dialog-source-boundary.test.ts @@ -242,4 +242,23 @@ describe('GitHubItemDialog source host boundaries', () => { expect(checksTab).toContain('item={displayWorkItem ?? workItem}') }) + + it('records state authority for dialog state mutations so stale list refetches cannot revert them (STA-3343)', () => { + const source = componentSource('GitHubItemDialog.tsx') + + // Issue close/reopen: assert on optimistic apply, revert on failure. + const editSection = sourceBetween(source, 'function GHEditSection', 'const closeAsDuplicate') + expect(editSection).toContain('assertTaskPageGitHubDialogStateAuthority({') + expect(editSection).toContain('if (authority?.revert())') + + // PR close/reopen + merge: same protection for the shared Tasks list rows. + const actionsSection = sourceBetween( + source, + 'function PRActionsPanel', + 'function CommentReplyForm' + ) + expect(actionsSection.match(/assertTaskPageGitHubDialogStateAuthority\(\{/g)).toHaveLength(2) + expect(actionsSection).toContain('if (authority.revert())') + expect(actionsSection).toContain("state: 'merged'") + }) }) diff --git a/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts b/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts new file mode 100644 index 00000000000..2f4d1646d21 --- /dev/null +++ b/src/renderer/src/components/task-page-github-dialog-state-authority.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { GitHubWorkItem } from '../../../shared/types' +import { getTaskSourceCacheScope } from '../../../shared/task-source-context' +import { assertTaskPageGitHubDialogStateAuthority } from './task-page-github-dialog-state-authority' +import { + adoptQuietSearchFieldsForItem, + applyPendingTaskPageGitHubMutationsToItems, + materializeTaskPageItemList, + reapplyPendingTaskPageGitHubMutationsToCache +} from './task-page-github-work-item-mutations' +import { + deleteLastConfirmedClientValue, + getLastConfirmedClientValue, + getOrCreateQuietRevalidateState, + resetTaskPageGitHubMutationRegistryForTests, + setLastConfirmedClientValue, + setTaskPageGitHubMutationQueryKey, + taskPageGitHubFamilyDirtyKey, + taskPageGitHubItemKey +} from './task-page-github-work-item-mutation-registry' + +function item(overrides: Partial = {}): GitHubWorkItem { + return { + id: 'issue:1', + type: 'issue', + number: 1, + title: 't', + state: 'open', + url: 'https://github.com/o/r/issues/1', + labels: [], + updatedAt: '2026-01-01T00:00:00Z', + author: 'author', + repoId: 'repo-1', + ...overrides + } +} + +const sourceContext = { + kind: 'task-source' as const, + provider: 'github' as const, + hostId: 'local' as const, + projectId: 'proj', + projectHostSetupId: 'setup', + repoId: 'repo-1' +} + +afterEach(() => resetTaskPageGitHubMutationRegistryForTests()) + +describe('dialog state authority (STA-3343)', () => { + it('without authority a stale search refetch reverts a cache-only patch (external edits adopt)', () => { + // Why: documents the pre-fix dialog behavior — patchWorkItem alone gives the + // row no registry protection, so the lagging search response wins. + const materialized = materializeTaskPageItemList({ + networkItems: [item({ state: 'open' })], + previousItems: [item({ state: 'closed' })], + queryKey: 'q' + }) + expect(materialized[0]?.state).toBe('open') + }) + + it('holds a dialog-confirmed close over a stale search refetch', () => { + setTaskPageGitHubMutationQueryKey('q') + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + const materialized = materializeTaskPageItemList({ + networkItems: [item({ state: 'open' })], + previousItems: [item({ state: 'closed' })], + queryKey: 'q' + }) + expect(materialized[0]?.state).toBe('closed') + }) + + it('holds the close through the cache re-apply path too', () => { + setTaskPageGitHubMutationQueryKey('q') + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + const store = new Map([['issue:1', item({ state: 'open' })]]) + reapplyPendingTaskPageGitHubMutationsToCache({ + items: [item({ state: 'open' })], + patchWorkItem: (id, patch) => { + store.set(id, { ...(store.get(id) ?? item()), ...patch }) + } + }) + expect(store.get('issue:1')?.state).toBe('closed') + }) + + it('records authority under the mutation source scope so other scopes are untouched', () => { + setTaskPageGitHubMutationQueryKey('q') + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed', + sourceContext + }) + const scope = getTaskSourceCacheScope(sourceContext) + expect(getLastConfirmedClientValue(scope, 'repo-1', 'issue:1', 'state')).toBe('closed') + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined() + // Why: overlay resolves the remembered scope, so the list row still holds. + const overlaid = applyPendingTaskPageGitHubMutationsToItems([item({ state: 'open' })]) + expect(overlaid[0]?.state).toBe('closed') + }) + + it('marks the state family dirty so quiet revalidation trails the stale row', () => { + setTaskPageGitHubMutationQueryKey('q') + const before = getOrCreateQuietRevalidateState('q').dirtyGeneration + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + const quiet = getOrCreateQuietRevalidateState('q') + expect(quiet.dirtyGeneration).toBe(before + 1) + expect( + quiet.familyDirtyAt.get( + taskPageGitHubFamilyDirtyKey(taskPageGitHubItemKey('repo-1', 'issue:1'), 'state') + ) + ).toBe(quiet.dirtyGeneration) + }) + + it('releases authority once search reports the confirmed state, so later external edits win', () => { + setTaskPageGitHubMutationQueryKey('q') + assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + const patched: Partial[] = [] + adoptQuietSearchFieldsForItem({ + item: item({ state: 'closed' }), + serverItem: item({ state: 'closed' }), + sourceScope: null, + queryKey: 'q', + fetchStartedAtGeneration: getOrCreateQuietRevalidateState('q').dirtyGeneration, + patchWorkItem: (_id, patch) => patched.push(patch) + }) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined() + const overlaid = applyPendingTaskPageGitHubMutationsToItems([item({ state: 'open' })]) + expect(overlaid[0]?.state).toBe('open') + }) + + it('revert drops fresh authority and restores a pre-existing value', () => { + setTaskPageGitHubMutationQueryKey('q') + const fresh = assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + expect(fresh.revert()).toBe(true) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined() + + setLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', 'open') + const layered = assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + expect(layered.revert()).toBe(true) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('open') + }) + + it('does not roll back authority released by search or superseded by a newer state', () => { + setTaskPageGitHubMutationQueryKey('q') + const released = assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + deleteLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state') + expect(released.revert()).toBe(false) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBeUndefined() + + const superseded = assertTaskPageGitHubDialogStateAuthority({ + repoId: 'repo-1', + itemId: 'issue:1', + state: 'closed' + }) + setLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state', 'merged') + expect(superseded.revert()).toBe(false) + expect(getLastConfirmedClientValue(null, 'repo-1', 'issue:1', 'state')).toBe('merged') + }) +}) diff --git a/src/renderer/src/components/task-page-github-dialog-state-authority.ts b/src/renderer/src/components/task-page-github-dialog-state-authority.ts new file mode 100644 index 00000000000..a372762e141 --- /dev/null +++ b/src/renderer/src/components/task-page-github-dialog-state-authority.ts @@ -0,0 +1,59 @@ +import type { GitHubWorkItem } from '../../../shared/types' +import { + getTaskSourceCacheScope, + type TaskSourceContext +} from '../../../shared/task-source-context' +import { + deleteLastConfirmedClientValue, + getLastConfirmedClientValue, + getTaskPageGitHubMutationQueryKey, + markTaskPageGitHubFamiliesDirty, + notifyTaskPageGitHubMutationRegistry, + setLastConfirmedClientValue, + taskPageGitHubItemKey +} from './task-page-github-work-item-mutation-registry' + +function markStateFamilyDirty(repoId: string, itemId: string): void { + const queryKey = getTaskPageGitHubMutationQueryKey() + if (queryKey !== null) { + markTaskPageGitHubFamiliesDirty(queryKey, taskPageGitHubItemKey(repoId, itemId), ['state']) + } +} + +/** + * Dialog state edits patch `workItemsCache` directly with no pending op, so a + * search-lagged list refetch (GitHub search index + `gh` URL cache) silently + * reverted them in the Tasks list (STA-3343). Record the confirmed state in the + * mutation registry so the list fetch paths re-assert it until search catches + * up; quiet adopt releases it on match, so external reverts still win. + */ +export function assertTaskPageGitHubDialogStateAuthority(args: { + repoId: string + itemId: string + state: GitHubWorkItem['state'] + sourceContext?: TaskSourceContext | null +}): { revert: () => boolean } { + const sourceScope = + args.sourceContext?.provider === 'github' ? getTaskSourceCacheScope(args.sourceContext) : null + const previous = getLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state') + setLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state', args.state) + markStateFamilyDirty(args.repoId, args.itemId) + notifyTaskPageGitHubMutationRegistry() + return { + revert: () => { + const current = getLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state') + // A matching search adopt or newer mutation owns the state now. + if (current !== args.state) { + return false + } + if (previous === undefined) { + deleteLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state') + } else { + setLastConfirmedClientValue(sourceScope, args.repoId, args.itemId, 'state', previous) + } + markStateFamilyDirty(args.repoId, args.itemId) + notifyTaskPageGitHubMutationRegistry() + return true + } + } +} From 790ade578a488cdf9d2c4470c7aa1a4cd3321496 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:27:38 -0700 Subject: [PATCH 34/46] fix(browser): recover browser tab when guest WebContents is destroyed (STA-3448) (#12831) * fix(browser): recover browser tab when guest WebContents is destroyed (STA-3448) A whose guest WebContents died without render-process-gone (detach/reattach race, guest-side close) stayed attached and painted black forever; reload and focus on the dead guest threw uncaught. - listen for the webview 'destroyed' event at both layers, mirroring render-process-gone: registry marks recovery pending (survives pane unmount), BrowserPane triggers guest recovery immediately - route reload-on-dead-guest into guest recovery instead of throwing (toolbar, Cmd+R renderer+IPC paths, context menu) - guard webview.focus() against Electron's null-internals throw * fix(browser): close guest reload destruction race --- .../components/browser-pane/BrowserPane.tsx | 55 +++++++++++----- .../browser-reload-action.test.ts | 62 ++++++++++++++++++- .../browser-pane/browser-reload-action.ts | 34 ++++++++++ .../browser-pane/webview-registry.test.ts | 39 ++++++++++++ .../browser-pane/webview-registry.ts | 18 +++++- 5 files changed, 191 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index a452757ecc4..d1aca3c49b6 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -197,6 +197,7 @@ import { import { subscribeBrowserSystemResume } from './browser-system-resume' import { type BrowserReloadTrigger, + reloadBrowserPageWebview, resolveBrowserReloadButtonLabelKind, resolveBrowserReloadIntent } from './browser-reload-action' @@ -2941,6 +2942,20 @@ function BrowserPagePane({ () => ({ loading: browserTab.loading, loadErrorCode: browserTab.loadError?.code ?? null }), [browserTab.loading, browserTab.loadError] ) + const reloadWebviewOrRecoverGuest = useCallback( + (ignoreCache: boolean) => { + const webview = webviewRef.current + if (!webview) { + return + } + if (reloadBrowserPageWebview(webview, { ignoreCache }) === 'guest-missing') { + // Why: reload cannot revive a destroyed guest (STA-3448) — recreate it instead. + onUpdatePageStateRef.current(browserTab.id, { loading: true }) + retryGuestRecoveryRef.current() + } + }, + [browserTab.id] + ) const runReloadTrigger = useCallback( (trigger: BrowserReloadTrigger) => { const webview = webviewRef.current @@ -2959,14 +2974,14 @@ function BrowserPagePane({ retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current) break case 'hard-reload': - webview.reloadIgnoringCache() + reloadWebviewOrRecoverGuest(true) break case 'reload': - webview.reload() + reloadWebviewOrRecoverGuest(false) break } }, - [browserTab, reloadState] + [browserTab, reloadState, reloadWebviewOrRecoverGuest] ) // Keep the accessible name honest: the same button is Stop mid-load and Retry after a failure. @@ -3454,7 +3469,12 @@ function BrowserPagePane({ return false } addressBarInputRef.current?.blur() - webview.focus() + try { + webview.focus() + } catch { + // Why: WebViewElement.focus() reads null internals once the guest is destroyed (STA-3448). + return false + } return document.activeElement === webview }, []) @@ -3660,15 +3680,11 @@ function BrowserPagePane({ } e.preventDefault() e.stopPropagation() - if (isHardReload) { - webviewRef.current?.reloadIgnoringCache() - } else { - webviewRef.current?.reload() - } + reloadWebviewOrRecoverGuest(isHardReload) } window.addEventListener('keydown', handleKeyDown, true) return () => window.removeEventListener('keydown', handleKeyDown, true) - }, [isActive, keybindings]) + }, [isActive, keybindings, reloadWebviewOrRecoverGuest]) // Cmd/Ctrl+R — reload (IPC path: focus inside webview guest) // Why: a focused guest is a separate Chromium process, so main forwards the chord back here. @@ -3677,9 +3693,9 @@ function BrowserPagePane({ return } return window.api.ui.onReloadBrowserPage(() => { - webviewRef.current?.reload() + reloadWebviewOrRecoverGuest(false) }) - }, [isActive]) + }, [isActive, reloadWebviewOrRecoverGuest]) useEffect(() => { if (!isActive) { @@ -3716,9 +3732,9 @@ function BrowserPagePane({ return } return window.api.ui.onHardReloadBrowserPage(() => { - webviewRef.current?.reloadIgnoringCache() + reloadWebviewOrRecoverGuest(true) }) - }, [isActive]) + }, [isActive, reloadWebviewOrRecoverGuest]) useEffect(() => { onUpdatePageStateRef.current = onUpdatePageState @@ -4220,9 +4236,17 @@ function BrowserPagePane({ validateVisibleGuestRegistrationRef.current = guestRecovery.validateAfterResume retryGuestRecoveryRef.current = guestRecovery.retryRecovery + const handleGuestDestroyed = (): void => { + // Why: a guest can be destroyed without render-process-gone (detach/reattach race, + // guest-side close). Skip intentional teardown, where the element already left the DOM. + if (webview.isConnected) { + guestRecovery.recoverRenderer() + } + } webview.addEventListener('did-attach', handleDidAttach) webview.addEventListener('dom-ready', handleDomReady) webview.addEventListener('render-process-gone', guestRecovery.recoverRenderer) + webview.addEventListener('destroyed', handleGuestDestroyed) webview.addEventListener('focus', dismissAddressBarSuggestions) webview.addEventListener('did-start-loading', handleDidStartLoading) webview.addEventListener('did-start-navigation', handleDidStartNavigation) @@ -4259,6 +4283,7 @@ function BrowserPagePane({ webview.removeEventListener('did-attach', handleDidAttach) webview.removeEventListener('dom-ready', handleDomReady) webview.removeEventListener('render-process-gone', guestRecovery.recoverRenderer) + webview.removeEventListener('destroyed', handleGuestDestroyed) webview.removeEventListener('focus', dismissAddressBarSuggestions) webview.removeEventListener('did-start-loading', handleDidStartLoading) webview.removeEventListener('did-start-navigation', handleDidStartNavigation) @@ -5161,7 +5186,7 @@ function BrowserPagePane({ role="menuitem" className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14" onClick={() => { - webviewRef.current?.reload() + reloadWebviewOrRecoverGuest(false) setContextMenu(null) }} > diff --git a/src/renderer/src/components/browser-pane/browser-reload-action.test.ts b/src/renderer/src/components/browser-pane/browser-reload-action.test.ts index ec14e666826..2f9748767a1 100644 --- a/src/renderer/src/components/browser-pane/browser-reload-action.test.ts +++ b/src/renderer/src/components/browser-pane/browser-reload-action.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery' import { + reloadBrowserPageWebview, resolveBrowserReloadButtonLabelKind, resolveBrowserReloadIntent } from './browser-reload-action' @@ -51,3 +52,62 @@ describe('resolveBrowserReloadButtonLabelKind', () => { expect(resolveBrowserReloadButtonLabelKind(guestFailed)).toBe('retry') }) }) + +describe('reloadBrowserPageWebview', () => { + function createWebview(overrides: Partial = {}): Electron.WebviewTag { + return { + getWebContentsId: vi.fn(() => 42), + reload: vi.fn(), + reloadIgnoringCache: vi.fn(), + ...overrides + } as unknown as Electron.WebviewTag + } + + it('reloads a live guest, honoring the cache flag', () => { + const webview = createWebview() + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('reloaded') + expect(webview.reload).toHaveBeenCalledTimes(1) + + expect(reloadBrowserPageWebview(webview, { ignoreCache: true })).toBe('reloaded') + expect(webview.reloadIgnoringCache).toHaveBeenCalledTimes(1) + }) + + // Why: a destroyed guest makes reload() throw uncaught (STA-3448) — callers must recreate the guest instead. + it('reports a missing guest without touching reload', () => { + const webview = createWebview({ + getWebContentsId: vi.fn(() => { + throw new Error('WebContents is destroyed') + }) + }) + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('guest-missing') + expect(webview.reload).not.toHaveBeenCalled() + }) + + it('reports a guest destroyed between the liveness probe and reload', () => { + const getWebContentsId = vi + .fn<() => number>() + .mockReturnValueOnce(42) + .mockImplementation(() => { + throw new Error('WebContents is destroyed') + }) + const webview = createWebview({ + getWebContentsId, + reload: vi.fn(() => { + throw new Error('The WebView must be attached to the DOM') + }) + }) + + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('guest-missing') + expect(getWebContentsId).toHaveBeenCalledTimes(2) + }) + + // Why: pre-dom-ready reload throws too, but a live guest is already loading — recovery must not replace it. + it('reports not-ready when a live guest rejects the reload', () => { + const webview = createWebview({ + reload: vi.fn(() => { + throw new Error('The WebView must be attached to the DOM') + }) + }) + expect(reloadBrowserPageWebview(webview, { ignoreCache: false })).toBe('not-ready') + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-reload-action.ts b/src/renderer/src/components/browser-pane/browser-reload-action.ts index 2bebeaa9cd9..b4ecb6d9b4f 100644 --- a/src/renderer/src/components/browser-pane/browser-reload-action.ts +++ b/src/renderer/src/components/browser-pane/browser-reload-action.ts @@ -34,6 +34,40 @@ export function resolveBrowserReloadIntent( return trigger === 'hard-reload' ? 'hard-reload' : 'reload' } +export type BrowserPageWebviewReloadResult = 'reloaded' | 'guest-missing' | 'not-ready' + +/** + * Why: reload on a guestless webview throws uncaught ("The WebView must be attached to the + * DOM…", STA-3448). getWebContentsId() only needs an attached guest, so it separates a dead + * guest (route to guest recovery) from a live pre-dom-ready one (a load is already in flight). + */ +export function reloadBrowserPageWebview( + webview: Electron.WebviewTag, + { ignoreCache }: { ignoreCache: boolean } +): BrowserPageWebviewReloadResult { + try { + webview.getWebContentsId() + } catch { + return 'guest-missing' + } + try { + if (ignoreCache) { + webview.reloadIgnoringCache() + } else { + webview.reload() + } + } catch { + try { + webview.getWebContentsId() + } catch { + // Why: the guest can be destroyed between the liveness probe and reload. + return 'guest-missing' + } + return 'not-ready' + } + return 'reloaded' +} + /** Accessible name for the toolbar button, which is Stop mid-load and Retry after a failure. */ export type BrowserReloadButtonLabelKind = 'stop' | 'retry' | 'reload' diff --git a/src/renderer/src/components/browser-pane/webview-registry.test.ts b/src/renderer/src/components/browser-pane/webview-registry.test.ts index b4abfee6b7e..c1ec7181e85 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.test.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.test.ts @@ -165,6 +165,45 @@ describe('webview registry drag listeners', () => { expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) }) + it('flags renderer recovery when the guest is destroyed under a still-attached webview', async () => { + const { isBrowserPageRendererRecoveryPending, registerPersistentWebview } = + await import('./webview-registry') + const webview = createWebview({ isConnected: true }) + registerPersistentWebview('page-1', webview) + + webview.dispatchEvent(new Event('destroyed')) + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(true) + + webview.dispatchEvent(new Event('dom-ready')) + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) + }) + + it('ignores guest destruction caused by intentional webview removal', async () => { + const { isBrowserPageRendererRecoveryPending, registerPersistentWebview } = + await import('./webview-registry') + const webview = createWebview({ isConnected: false }) + registerPersistentWebview('page-1', webview) + + webview.dispatchEvent(new Event('destroyed')) + + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) + }) + + it('stops listening for guest destruction after unregistration', async () => { + const { + isBrowserPageRendererRecoveryPending, + registerPersistentWebview, + unregisterPersistentWebview + } = await import('./webview-registry') + const webview = createWebview({ isConnected: true }) + registerPersistentWebview('page-1', webview) + unregisterPersistentWebview('page-1') + + webview.dispatchEvent(new Event('destroyed')) + + expect(isBrowserPageRendererRecoveryPending('page-1')).toBe(false) + }) + it('preserves the viewport and zoom only while replacing a guest', async () => { const { destroyPersistentWebview, registerPersistentWebview, replacePersistentWebview } = await import('./webview-registry') diff --git a/src/renderer/src/components/browser-pane/webview-registry.ts b/src/renderer/src/components/browser-pane/webview-registry.ts index 3ce94e15dad..f64f0dfc0e3 100644 --- a/src/renderer/src/components/browser-pane/webview-registry.ts +++ b/src/renderer/src/components/browser-pane/webview-registry.ts @@ -27,6 +27,7 @@ const webviewLifecycleListeners = new Map< webview: Electron.WebviewTag onRendererGone: EventListener onRendererReady: EventListener + onGuestDestroyed: EventListener } >() @@ -163,6 +164,7 @@ export function registerPersistentWebview( previousListeners.onRendererGone ) previousListeners.webview.removeEventListener('dom-ready', previousListeners.onRendererReady) + previousListeners.webview.removeEventListener('destroyed', previousListeners.onGuestDestroyed) } const onRendererGone = (): void => { rendererRecoveryPendingPageIds.add(browserTabId) @@ -170,9 +172,22 @@ export function registerPersistentWebview( const onRendererReady = (): void => { rendererRecoveryPendingPageIds.delete(browserTabId) } + const onGuestDestroyed = (): void => { + // Why: 'destroyed' also fires after an intentional webview.remove(); only a + // still-attached element means the guest died under a live tab (STA-3448). + if (webview.isConnected) { + rendererRecoveryPendingPageIds.add(browserTabId) + } + } webview.addEventListener('render-process-gone', onRendererGone) webview.addEventListener('dom-ready', onRendererReady) - webviewLifecycleListeners.set(browserTabId, { webview, onRendererGone, onRendererReady }) + webview.addEventListener('destroyed', onGuestDestroyed) + webviewLifecycleListeners.set(browserTabId, { + webview, + onRendererGone, + onRendererReady, + onGuestDestroyed + }) webviewRegistry.set(browserTabId, webview) applyCurrentDragPassthroughToWebview(webview) ensureDragListeners() @@ -187,6 +202,7 @@ export function unregisterPersistentWebview(browserTabId: string): void { lifecycleListeners.onRendererGone ) lifecycleListeners.webview.removeEventListener('dom-ready', lifecycleListeners.onRendererReady) + lifecycleListeners.webview.removeEventListener('destroyed', lifecycleListeners.onGuestDestroyed) webviewLifecycleListeners.delete(browserTabId) } rendererRecoveryPendingPageIds.delete(browserTabId) From ab1bf1aff535b048af3d399d7da904eedcdeb1b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:40:27 -0700 Subject: [PATCH 35/46] fix(settings): recover HTTP proxy settings from safeStorage failures (STA-3442) (#12838) httpProxyUrl is the only network setting stored via safeStorage. Two failure modes silently killed the configured proxy on macOS: - A keychain reset/denial makes decryptString throw at load; the raw ciphertext then masqueraded as the configured proxy URL, so applyElectronProxySettings silently fell back to DIRECT and the garbage re-persisted forever (no self-heal). - safeStorage.isEncryptionAvailable() throwing (keychain/API errors, pre-ready use) was uncaught in encrypt/decrypt, failing the entire state save - the data file never gained the proxy keys at all. Load now validates the decrypted value and clears undecryptable ciphertext (plaintext URLs still pass, preserving the pre-encryption upgrade path), the availability check is exception-safe, and startup logs when persisted proxy settings are invalid instead of silently using direct networking. --- src/main/index.ts | 6 +- .../persistence-proxy-secret-recovery.test.ts | 186 ++++++++++++++++++ src/main/persistence.ts | 30 ++- 3 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 src/main/persistence-proxy-secret-recovery.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 61c7d799f94..ebabeb2b18e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2098,7 +2098,11 @@ void app.whenReady().then(async () => { } try { // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. - await applyElectronProxySettings(store.getSettings()) + const proxyApplyResult = await applyElectronProxySettings(store.getSettings()) + if (proxyApplyResult.source === 'invalid-settings') { + // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. + console.warn('[proxy] persisted proxy settings are invalid; using direct networking') + } } catch { console.warn('[proxy] Failed to apply network proxy settings') } diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts new file mode 100644 index 00000000000..bc6e03483bb --- /dev/null +++ b/src/main/persistence-proxy-secret-recovery.test.ts @@ -0,0 +1,186 @@ +// STA-3442: httpProxyUrl is the only network setting stored via safeStorage. +// On macOS a keychain reset/denial makes decryptString throw at load, and the +// raw ciphertext then masqueraded as a configured proxy: applyElectronProxySettings +// silently fell back to DIRECT and the garbage re-persisted forever. These tests +// pin the recovery contract: undecryptable values are cleared (never applied, +// never re-saved as garbage), plaintext values survive as the upgrade path, and +// a throwing safeStorage.isEncryptionAvailable() cannot kill the whole save. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' + +const testState = { dir: '' } + +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(), + sshConfigHostsToTargets: vi.fn() +})) + +const cipherState = { + encryptionAvailable: true, + availabilityThrows: false, + decryptAlwaysThrows: false +} + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + session: { defaultSession: undefined }, + safeStorage: { + isEncryptionAvailable: () => { + if (cipherState.availabilityThrows) { + throw new Error('safeStorage cannot be used before the app is ready') + } + return cipherState.encryptionAvailable + }, + encryptString: (plaintext: string) => Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8'), + decryptString: (ciphertext: Buffer) => { + if (cipherState.decryptAlwaysThrows) { + throw new Error('keychain access denied') + } + const decoded = ciphertext.toString('utf-8') + if (!decoded.startsWith('enc:')) { + throw new Error('invalid ciphertext') + } + return decoded.slice('enc:'.length + 36 + 1) + } + } +})) + +vi.mock('./telemetry/client', () => ({ + track: vi.fn() +})) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 }) +})) + +async function createStore() { + vi.resetModules() + const { Store, initDataPath } = await import('./persistence') + initDataPath() + return new Store() +} + +function dataFile(): string { + return join(testState.dir, 'orca-data.json') +} + +const PROXY_URL = 'http://127.0.0.1:8080' +const BYPASS_RULES = '' + +describe('httpProxyUrl secret recovery (STA-3442)', () => { + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + cipherState.encryptionAvailable = true + cipherState.availabilityThrows = false + cipherState.decryptAlwaysThrows = false + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + async function seedConfiguredProxy() { + const store = await createStore() + store.updateSettings({ httpProxyUrl: PROXY_URL, httpProxyBypassRules: BYPASS_RULES }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + return store + } + + it('persists the configured proxy across a restart and applies it as fixed_servers', async () => { + await seedConfiguredProxy() + + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { httpProxyUrl: string; httpProxyBypassRules: string } + } + // On disk the URL is ciphertext (base64 of the mock's enc: payload), never plaintext. + expect(persisted.settings.httpProxyUrl).not.toBe(PROXY_URL) + expect(Buffer.from(persisted.settings.httpProxyUrl, 'base64').toString('utf-8')).toMatch( + /^enc:/ + ) + expect(persisted.settings.httpProxyBypassRules).toBe(BYPASS_RULES) + + const reloaded = await createStore() + expect(reloaded.getSettings().httpProxyUrl).toBe(PROXY_URL) + expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) + + const { applyElectronProxySettings, resetProxyApplicationForTests } = + await import('./network/proxy-settings') + resetProxyApplicationForTests() + const setProxy = vi.fn(async () => {}) + const result = await applyElectronProxySettings(reloaded.getSettings(), { + proxySession: { resolveProxy: async () => 'DIRECT', setProxy }, + env: {} + }) + expect(result).toEqual({ + source: 'settings', + proxyRules: PROXY_URL, + proxyBypassRules: BYPASS_RULES + }) + expect(setProxy).toHaveBeenCalledWith({ + mode: 'fixed_servers', + proxyRules: PROXY_URL, + proxyBypassRules: BYPASS_RULES + }) + }) + + it('clears an undecryptable httpProxyUrl instead of surfacing ciphertext as settings', async () => { + await seedConfiguredProxy() + + // Keychain reset/denial: every decrypt now fails. + cipherState.decryptAlwaysThrows = true + const reloaded = await createStore() + + expect(reloaded.getSettings().httpProxyUrl).toBe('') + // Bypass rules are not encrypted and must survive. + expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) + + // The cleanup must reach disk so garbage never round-trips back in. + vi.advanceTimersByTime(2000) + await reloaded.waitForPendingWrite() + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { httpProxyUrl: string } + } + expect(persisted.settings.httpProxyUrl).toBe('') + }) + + it('keeps a plaintext httpProxyUrl on disk readable (pre-encryption/hand-edited upgrade path)', async () => { + mkdirSync(dirname(dataFile()), { recursive: true }) + writeFileSync( + dataFile(), + JSON.stringify({ settings: { httpProxyUrl: PROXY_URL, httpProxyBypassRules: BYPASS_RULES } }), + 'utf-8' + ) + + const store = await createStore() + expect(store.getSettings().httpProxyUrl).toBe(PROXY_URL) + expect(store.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES) + }) + + it('still saves and reloads the proxy when safeStorage.isEncryptionAvailable throws', async () => { + cipherState.availabilityThrows = true + + const store = await createStore() + store.updateSettings({ httpProxyUrl: PROXY_URL, httpProxyBypassRules: BYPASS_RULES }) + vi.advanceTimersByTime(1000) + await store.waitForPendingWrite() + + // Encryption degraded to plaintext passthrough; the save must not be lost. + expect(existsSync(dataFile())).toBe(true) + const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as { + settings: { httpProxyUrl: string; httpProxyBypassRules: string } + } + expect(persisted.settings.httpProxyUrl).toBe(PROXY_URL) + expect(persisted.settings.httpProxyBypassRules).toBe(BYPASS_RULES) + + const reloaded = await createStore() + expect(reloaded.getSettings().httpProxyUrl).toBe(PROXY_URL) + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index be3f10fd78f..565b6fd0032 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -38,6 +38,7 @@ import { } from '../shared/automation-schedules' import { getAutomationLegacyRepoId } from '../shared/automation-run-identity' import { normalizeAutomationPrecheck } from '../shared/automation-precheck' +import { normalizeProxyUrl } from '../shared/network-proxy' import type { PersistedState, Project, @@ -279,8 +280,19 @@ import { track } from './telemetry/client' import { getCohortAtEmit } from './telemetry/cohort-classifier' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics' +// Why (STA-3442): isEncryptionAvailable() itself can throw (keychain/API errors, pre-ready +// use); an uncaught throw here failed the entire save/load, silently losing every setting. +function safeStorageEncryptionAvailable(): boolean { + try { + return safeStorage.isEncryptionAvailable() + } catch (err) { + console.warn('[persistence] safeStorage availability check failed:', err) + return false + } +} + function encrypt(plaintext: string): string { - if (!plaintext || !safeStorage.isEncryptionAvailable()) { + if (!plaintext || !safeStorageEncryptionAvailable()) { return plaintext } try { @@ -292,7 +304,7 @@ function encrypt(plaintext: string): string { } function decrypt(ciphertext: string): string { - if (!ciphertext || !safeStorage.isEncryptionAvailable()) { + if (!ciphertext || !safeStorageEncryptionAvailable()) { return ciphertext } try { @@ -3067,7 +3079,19 @@ export class Store { parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie) } if (parsed.settings?.httpProxyUrl) { - parsed.settings.httpProxyUrl = decrypt(parsed.settings.httpProxyUrl) + const decryptedProxyUrl = decrypt(parsed.settings.httpProxyUrl) + // Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL + // value must not masquerade as a configured proxy (silent DIRECT fallback) or + // re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path. + if (normalizeProxyUrl(decryptedProxyUrl).ok) { + parsed.settings.httpProxyUrl = decryptedProxyUrl + } else { + console.warn( + '[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.' + ) + parsed.settings.httpProxyUrl = '' + this.loadNeedsSave = true + } } if (parsed.ui?.browserKagiSessionLink) { parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink) From 12472b8a63d640d196396e5d84e7da8c4ccf796a Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:43:22 +0900 Subject: [PATCH 36/46] fix(skills): match official skill files despite local sidecars (#12812) * fix(skills): match official skill files despite local sidecars Scope known-snapshot matching to manifest-listed files so agent-written sidecars (e.g. agents/openai.yaml) no longer mark a package unrecognized and block updates when official bytes still match. Preserves fail-closed detection when a listed file's content drifts. Fixes #12694 * fix(skills): scope lock trust and convergence to official files too Sidecar tolerance stopped at the snapshot match, leaving three disk-vs-official comparisons still judging the whole folder. The lock-comparable hash covered every observed file, so a clean update beside agents/openai.yaml reported as failed and read 'may be modified'. It is now carried both whole and scoped to the current bundle's paths, and either may satisfy the lock: the sidecar case only ever matches scoped, while an upstream revision that ADDS a file only ever matches whole, so publishing one alone would trade this bug for #11220. Convergence re-derived the disk revision from that same whole-folder digest, which no revision matches once a sidecar lands, retiring the stuck-lock gate and arming an update the command provably cannot perform; it now honours the revision observation already resolved. Subset matching also let an older revision launder drift on a file the current bundle lists, since that revision does not list it and so read it as a neighbour. Identity now keys tolerance on what the current bundle owns. --------- Co-authored-by: Jinwoo-H --- .../skills/skill-freshness-inventory.test.ts | 151 +++++++++++++++- src/main/skills/skill-freshness-inventory.ts | 5 +- .../skill-freshness-placement-observation.ts | 16 +- .../skills/skill-package-identity.test.ts | 170 +++++++++++++++--- src/main/skills/skill-package-identity.ts | 69 ++++++- .../skills/skill-update-convergence.test.ts | 112 ++++++------ src/main/skills/skill-update-convergence.ts | 39 ++-- src/main/skills/skill-update-outcome.ts | 7 +- src/main/skills/skill-update-registration.ts | 27 +++ src/shared/skill-freshness.ts | 7 + 10 files changed, 477 insertions(+), 126 deletions(-) diff --git a/src/main/skills/skill-freshness-inventory.test.ts b/src/main/skills/skill-freshness-inventory.test.ts index 2b20055e646..f2cb31e1357 100644 --- a/src/main/skills/skill-freshness-inventory.test.ts +++ b/src/main/skills/skill-freshness-inventory.test.ts @@ -274,6 +274,139 @@ describe('read-only skill freshness inventory', () => { expect(inventory.eligibleUpdateNames).toEqual([]) }) + it('scopes the lock hash to the current bundle, not every path a revision ever shipped', async () => { + // A file revision 1 shipped and the current revision dropped is a stale leftover, not + // part of what the updater installed. Scoping to the union of all revisions would drag + // it back into the hash on the accident of its name, and the copy the CLI just wrote + // would read "may be modified". + const test = await fixture() + const legacy = describeObservedSkillFile( + 'references/legacy.md', + Buffer.from('dropped after rev 1\n'), + false + ) + const registryPath = join(test.resourceRoot, 'skills', 'snapshot-registry.json') + const registry = JSON.parse(await readFile(registryPath, 'utf8')) + registry.skills['orca-cli'][0].files.push(legacy) + await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`) + + const upstreamMarkdown = `${test.newerMarkdown}\nUpstream edit no bundle has shipped.\n` + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + upstreamMarkdown + ) + // The lock records the source tree — SKILL.md alone, no leftover. + const sourceTree = await test.writeSkill(join(test.root, 'source'), upstreamMarkdown) + await writeSkillLockHash(test.homeDir, await gitTreeShaOf(sourceTree)) + await mkdir(join(canonical, 'references'), { recursive: true }) + await writeFile(join(canonical, 'references', 'legacy.md'), 'dropped after rev 1\n') + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'newer-known' + }) + expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') + }) + + it('trusts the updater lock for upstream bytes beside an agent CLI sidecar (#12694)', async () => { + // The reported folder shape after a successful update: `skills update` wrote + // source-repo HEAD no bundle knows yet, and Codex's own agents/openai.yaml sits + // beside it. The lock records the source tree — SKILL.md alone — so a folder hash + // taken over the sidecar too can never match it, and the copy the command just + // wrote would be reported as a failed update. + const test = await fixture() + const upstreamMarkdown = `${test.newerMarkdown}\nUpstream edit no bundle has shipped.\n` + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + upstreamMarkdown + ) + const sourceTree = await test.writeSkill(join(test.root, 'source'), upstreamMarkdown) + await writeSkillLockHash(test.homeDir, await gitTreeShaOf(sourceTree)) + await mkdir(join(canonical, 'agents'), { recursive: true }) + await writeFile(join(canonical, 'agents', 'openai.yaml'), 'display_name: test\n') + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'newer-known' + }) + expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') + }) + + it('still trusts the lock when the upstream revision added a file (#11220 guard)', async () => { + // The other half of the sidecar fix: scoping the lock hash to official paths alone + // would drop a file upstream genuinely shipped, and that file IS in the lock's tree. + // A clean install would then read "may be modified" and its update run report failure. + const test = await fixture() + const upstreamMarkdown = `${test.newerMarkdown}\nUpstream edit no bundle has shipped.\n` + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + upstreamMarkdown + ) + await mkdir(join(canonical, 'references'), { recursive: true }) + await writeFile( + join(canonical, 'references', 'new.md'), + 'Shipped upstream, no bundle knows it\n' + ) + await writeSkillLockHash(test.homeDir, await gitTreeShaOf(canonical)) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'newer-known' + }) + expect(getSkillFreshnessDisplayStatus(inventory, 'orca-cli')).toBe('up-to-date') + }) + + it('still withholds an unwinnable update when a sidecar sits beside the stale copy', async () => { + // Recognising the copy is only half the job: the lock already names the revision the + // source has, so `skills update` would compare lock to source, see no work and write + // nothing. Offering it promises a button that can never finish. A sidecar must not + // make the placement unidentifiable and retire that guard. + const test = await fixture() + const canonical = await test.writeSkill( + join(test.homeDir, '.agents', 'skills'), + test.oldMarkdown + ) + await mkdir(join(canonical, 'agents'), { recursive: true }) + await writeFile(join(canonical, 'agents', 'openai.yaml'), 'display_name: test\n') + // The fixture's synthetic tree sha for revision 2 — the revision on disk is 1. + await writeSkillLockHash(test.homeDir, (2).toString(16).padStart(40, '0')) + + const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', + homeDir: test.homeDir, + repos: [], + resourceRoot: test.resourceRoot + }) + + expect(inventory.installations[0]).toMatchObject({ + topology: 'canonical-copy', + status: 'outdated', + installedReleaseRevision: 1 + }) + expect(inventory.eligibleUpdateNames).toEqual([]) + }) + it('still flags canonical bytes that do not match what the lock says was installed', async () => { const test = await fixture() const editedMarkdown = `${test.currentMarkdown}\nLocal edit the updater never wrote.\n` @@ -522,10 +655,14 @@ describe('read-only skill freshness inventory', () => { expect(inventory.eligibleUpdateNames).toEqual([]) }) - it('keeps a plugin-cache copy with known official files unrecognized', async () => { + it('reads a plugin-cache copy with untouched official files as current', async () => { + // The deliberate posture change behind #12694: an unlisted neighbour is not evidence + // of an edit, so the bytes Orca owns decide alone — here and in every scope, not just + // the canonical copy the updater writes. The drifted-SKILL.md case above still fails + // closed, which is what keeps "unrecognized" meaningful. const test = await fixture() await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) - const modifiedRoot = join( + const withSidecarRoot = join( test.homeDir, '.codex', 'plugins', @@ -534,9 +671,9 @@ describe('read-only skill freshness inventory', () => { 'modified', 'orca-cli' ) - await mkdir(modifiedRoot, { recursive: true }) - await writeFile(join(modifiedRoot, 'SKILL.md'), test.currentMarkdown) - await writeFile(join(modifiedRoot, 'README.md'), 'Modified official package\n') + await mkdir(withSidecarRoot, { recursive: true }) + await writeFile(join(withSidecarRoot, 'SKILL.md'), test.currentMarkdown) + await writeFile(join(withSidecarRoot, 'README.md'), 'Neighbouring file Orca never shipped\n') const inventory = await inventorySkillFreshness({ currentAppVersion: '2.0.0', @@ -548,8 +685,8 @@ describe('read-only skill freshness inventory', () => { expect(inventory.installations).toEqual( expect.arrayContaining([ expect.objectContaining({ - unresolvedPath: modifiedRoot, - status: 'unrecognized' + unresolvedPath: withSidecarRoot, + status: 'current' }) ]) ) diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts index a3747451898..f502707321e 100644 --- a/src/main/skills/skill-freshness-inventory.ts +++ b/src/main/skills/skill-freshness-inventory.ts @@ -19,7 +19,7 @@ import { } from './skill-freshness-placement-observation' import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan' import { convergableSkillNames } from './skill-update-convergence' -import { readGloballyUpdatableSkillLocks } from './skill-update-registration' +import { matchesUpdaterLock, readGloballyUpdatableSkillLocks } from './skill-update-registration' export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128 @@ -35,8 +35,7 @@ function trustLockInstalledRevision( ): SkillFreshnessInstallation { return installation.status === 'unrecognized' && SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(installation.topology) && - installation.observedGitTreeSha != null && - installation.observedGitTreeSha === globalSkillLocks.get(installation.name) + matchesUpdaterLock(installation, globalSkillLocks.get(installation.name)) ? { ...installation, status: 'newer-known' } : installation } diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts index 87911de4823..02c9dcf80f5 100644 --- a/src/main/skills/skill-freshness-placement-observation.ts +++ b/src/main/skills/skill-freshness-placement-observation.ts @@ -8,7 +8,11 @@ import type { } from '../../shared/skill-freshness' import type { SkillScanRoot } from './skill-discovery-sources' import type { SkillBundleArtifacts } from './skill-bundle-artifacts' -import { matchingKnownSnapshot, observeSkillPackage } from './skill-package-identity' +import { + matchingKnownSnapshot, + observeSkillPackage, + officialPathsGitTreeSha +} from './skill-package-identity' import { classifyHomeSkillTopology, classifyUnsupportedSkillTopology, @@ -95,10 +99,9 @@ export async function observeSkillFreshnessInstallation(args: { try { const observed = await observeSkillPackage(args.topology.resolvedPath) - const matchedSnapshot = matchingKnownSnapshot( - observed, - knownSnapshots(args.artifacts, args.current) - ) + const snapshots = knownSnapshots(args.artifacts, args.current) + const officialPaths = new Set(args.current.files.map((file) => file.path)) + const matchedSnapshot = matchingKnownSnapshot(observed, snapshots, officialPaths) // Why: a later release can reintroduce identical bytes. Exact current // identity is still current, and cannot honestly be attributed to the later tag. const snapshot = @@ -117,7 +120,8 @@ export async function observeSkillFreshnessInstallation(args: { null) : null, observedPackageDigest: observed.observedDigest, - observedGitTreeSha: observed.observedGitTreeSha + observedGitTreeSha: observed.observedGitTreeSha, + observedOfficialGitTreeSha: officialPathsGitTreeSha(observed, officialPaths) } } catch (error) { return { diff --git a/src/main/skills/skill-package-identity.test.ts b/src/main/skills/skill-package-identity.test.ts index ca9beb9fd3d..7d362be9e7d 100644 --- a/src/main/skills/skill-package-identity.test.ts +++ b/src/main/skills/skill-package-identity.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -6,6 +6,7 @@ import { describeObservedSkillFile, matchingKnownSnapshot, observeSkillPackage, + officialPathsGitTreeSha, skillPackageDigest } from './skill-package-identity' @@ -17,6 +18,13 @@ async function temporarySkill(): Promise { return root } +/** A byte-identical folder elsewhere, so a hash can be derived without the original. */ +async function copyOf(source: string): Promise { + const target = await temporarySkill() + await cp(source, target, { recursive: true }) + return target +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true }))) }) @@ -32,14 +40,18 @@ describe('skill package identity', () => { // hashes, not raw file buffers, should survive each file's identity pass. expect(observed.files[0]).not.toHaveProperty('bytes') expect( - matchingKnownSnapshot(observed, [ - { - releaseRevision: 1, - packageDigest: skillPackageDigest([expected]), - gitTreeSha: 'tree', - files: [expected] - } - ])?.releaseRevision + matchingKnownSnapshot( + observed, + [ + { + releaseRevision: 1, + packageDigest: skillPackageDigest([expected]), + gitTreeSha: 'tree', + files: [expected] + } + ], + new Set(['SKILL.md']) + )?.releaseRevision ).toBe(1) }) @@ -102,14 +114,18 @@ describe('skill package identity', () => { // has to come out clean too, not just the digest. expect(observed.observedGitTreeSha).toBe(official.observedGitTreeSha) expect( - matchingKnownSnapshot(observed, [ - { - releaseRevision: 1, - packageDigest: official.observedDigest, - gitTreeSha: official.observedGitTreeSha, - files: official.files - } - ])?.releaseRevision + matchingKnownSnapshot( + observed, + [ + { + releaseRevision: 1, + packageDigest: official.observedDigest, + gitTreeSha: official.observedGitTreeSha, + files: official.files + } + ], + new Set(['SKILL.md']) + )?.releaseRevision ).toBe(1) }) @@ -141,11 +157,13 @@ describe('skill package identity', () => { const edited = await temporarySkill() await writeFile(join(edited, 'SKILL.md'), 'skill\nlocal tweak\n') await writeFile(join(edited, '.DS_Store'), Buffer.from([0, 1])) - // An unexpected file that is NOT OS metadata must keep failing closed; tolerating - // extras in general would let an injected payload ride along beside a clean SKILL.md. + // Extra sidecar next to an untouched SKILL.md must still match — agent CLIs + // write agents/openai.yaml (and similar) without editing official bytes. + // Content drift on a listed file remains fail-closed. const withPayload = await temporarySkill() await writeFile(join(withPayload, 'SKILL.md'), 'skill\n') - await writeFile(join(withPayload, 'payload.sh'), '#!/bin/sh\n') + await mkdir(join(withPayload, 'agents'), { recursive: true }) + await writeFile(join(withPayload, 'agents', 'openai.yaml'), 'display_name: test\n') const snapshot = [ { @@ -155,8 +173,116 @@ describe('skill package identity', () => { files: official.files } ] - expect(matchingKnownSnapshot(await observeSkillPackage(edited), snapshot)).toBeNull() - expect(matchingKnownSnapshot(await observeSkillPackage(withPayload), snapshot)).toBeNull() + const official1 = new Set(['SKILL.md']) + expect(matchingKnownSnapshot(await observeSkillPackage(edited), snapshot, official1)).toBeNull() + expect( + matchingKnownSnapshot(await observeSkillPackage(withPayload), snapshot, official1) + ?.releaseRevision + ).toBe(1) + }) + + it('does not let an older revision launder drift on a file the current one lists', async () => { + // Subset matching treats a path the tested revision does not list as a neighbour. + // Left unguarded, revision 1 (SKILL.md alone) would match a revision-2 folder whose + // references/x.md had been rewritten — reporting a tampered package as merely + // outdated, with an update offered and no "may be modified" anywhere. + const pristine = await temporarySkill() + await writeFile(join(pristine, 'SKILL.md'), 'skill\n') + const revisionOne = await observeSkillPackage(pristine) + + const tampered = await temporarySkill() + await writeFile(join(tampered, 'SKILL.md'), 'skill\n') + await mkdir(join(tampered, 'references'), { recursive: true }) + await writeFile(join(tampered, 'references', 'x.md'), 'attacker content\n') + + const snapshots = [ + { + releaseRevision: 1, + packageDigest: revisionOne.observedDigest, + gitTreeSha: revisionOne.observedGitTreeSha, + files: revisionOne.files + } + ] + const observed = await observeSkillPackage(tampered) + + expect( + matchingKnownSnapshot(observed, snapshots, new Set(['SKILL.md', 'references/x.md'])) + ).toBeNull() + // The same folder is a plain sidecar case once the current bundle stops claiming + // that path, so the guard must key on what is official — not on file count. + expect(matchingKnownSnapshot(observed, snapshots, new Set(['SKILL.md']))?.releaseRevision).toBe( + 1 + ) + }) + + it('hashes official paths for the lock without hiding an upstream file it added', async () => { + const clean = await temporarySkill() + await writeFile(join(clean, 'SKILL.md'), 'skill\n') + const source = await observeSkillPackage(clean) + + const withSidecar = await temporarySkill() + await writeFile(join(withSidecar, 'SKILL.md'), 'skill\n') + await mkdir(join(withSidecar, 'agents'), { recursive: true }) + await writeFile(join(withSidecar, 'agents', 'openai.yaml'), 'display_name: test\n') + const sidecarObserved = await observeSkillPackage(withSidecar) + const officialPaths = new Set(['SKILL.md']) + + // A sidecar folder reaches the lock's source-tree hash only once scoped. + expect(sidecarObserved.observedGitTreeSha).not.toBe(source.observedGitTreeSha) + expect(officialPathsGitTreeSha(sidecarObserved, officialPaths)).toBe(source.observedGitTreeSha) + + // An upstream revision that ADDS a file puts it in the lock's own tree, so the + // whole-folder hash has to survive alongside — scoping it away would re-break the + // clean install this check exists to recognise (#11220). + const upstream = await temporarySkill() + await writeFile(join(upstream, 'SKILL.md'), 'skill\n') + await mkdir(join(upstream, 'references'), { recursive: true }) + await writeFile(join(upstream, 'references', 'new.md'), 'shipped upstream\n') + const upstreamObserved = await observeSkillPackage(upstream) + // The lock is the source tree the CLI installed, derived independently of the + // observation under test — comparing the observation to itself would assert nothing. + const upstreamLock = (await observeSkillPackage(await copyOf(upstream))).observedGitTreeSha + + expect(officialPathsGitTreeSha(upstreamObserved, officialPaths)).not.toBe(upstreamLock) + expect(upstreamObserved.observedGitTreeSha).toBe(upstreamLock) + }) + + it('scopes the lock hash to the current bundle, not every path ever shipped', async () => { + // A file an older revision shipped and the current one dropped is a stale leftover, + // not part of what the updater installed. Scoping to the union of all revisions would + // drag it back into the hash purely because its name was once official, and the copy + // would read "may be modified" over bytes the CLI itself wrote. + const source = await temporarySkill() + await writeFile(join(source, 'SKILL.md'), 'skill\n') + const lock = (await observeSkillPackage(source)).observedGitTreeSha + + const withLeftover = await temporarySkill() + await writeFile(join(withLeftover, 'SKILL.md'), 'skill\n') + await mkdir(join(withLeftover, 'references'), { recursive: true }) + await writeFile(join(withLeftover, 'references', 'legacy.md'), 'dropped after rev 1\n') + const observed = await observeSkillPackage(withLeftover) + + expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).toBe(lock) + // The union of every revision's paths — what scoping must NOT use. + expect( + officialPathsGitTreeSha(observed, new Set(['SKILL.md', 'references/legacy.md'])) + ).not.toBe(lock) + }) + + it('does not hand the lock an empty tree when no official path is present', async () => { + // git's empty-tree sha is a real, matchable value; returning it would let a lock that + // ever recorded an empty source tree vouch for any folder holding nothing official. + const root = await temporarySkill() + await mkdir(join(root, 'agents'), { recursive: true }) + await writeFile(join(root, 'agents', 'openai.yaml'), 'display_name: test\n') + const observed = await observeSkillPackage(root) + + expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).toBe( + observed.observedGitTreeSha + ) + expect(officialPathsGitTreeSha(observed, new Set(['SKILL.md']))).not.toBe( + '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + ) }) it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => { diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts index 4c883a2d6e6..5610479a4f7 100644 --- a/src/main/skills/skill-package-identity.ts +++ b/src/main/skills/skill-package-identity.ts @@ -14,8 +14,13 @@ type ObservedSkillFile = SkillBundleFileIdentity export type ObservedSkillPackage = { files: ObservedSkillFile[] observedDigest: string - /** Git tree sha of the raw bytes — comparable against the updater lock's skillFolderHash. */ + /** + * Git tree sha of every observed file's raw bytes — one of the two values comparable + * against the updater lock's `skillFolderHash` (see `officialPathsGitTreeSha`). + */ observedGitTreeSha: string + /** Retained so a subset of the observed paths can be re-hashed the same way. */ + treeEntries: SkillGitTreeFileEntry[] } // Why: package identity compares a live user directory against a tree the generator read @@ -250,21 +255,40 @@ export async function observeSkillPackage( return { files, observedDigest: skillPackageDigest(files), - observedGitTreeSha: skillPackageGitTreeSha(treeEntries) + observedGitTreeSha: skillPackageGitTreeSha(treeEntries), + treeEntries } } +/** + * The newest revision whose every listed file is present and byte-identical. + * + * `officialPaths` is what the CURRENT bundle says this skill owns, and it is what + * separates a tolerable neighbour from drift. Agent CLIs drop their own metadata + * beside an official SKILL.md (Codex writes `agents/openai.yaml` and cannot put it + * anywhere else), and a file no revision claims is not evidence the user edited + * anything — so extras alone must not mark the package unrecognized. + * + * A file the current bundle DOES list is never an extra, even when the revision + * being tested predates it: without that guard an older snapshot would happily match + * a folder whose newer official file had been tampered with, laundering real drift + * into a clean "outdated" row. Listed-file drift still fails closed either way. + */ export function matchingKnownSnapshot( observed: ObservedSkillPackage, - snapshots: readonly SkillKnownSnapshot[] + snapshots: readonly SkillKnownSnapshot[], + officialPaths: ReadonlySet ): SkillKnownSnapshot | null { + const observedByPath = new Map(observed.files.map((file) => [file.path, file])) for (const snapshot of snapshots.toReversed()) { - if (snapshot.files.length !== observed.files.length) { - continue - } + const listed = new Set(snapshot.files.map((file) => file.path)) + const launders = observed.files.some( + (file) => !listed.has(file.path) && officialPaths.has(file.path) + ) if ( - snapshot.files.every((expected, index) => { - const actual = observed.files[index] + !launders && + snapshot.files.every((expected) => { + const actual = observedByPath.get(expected.path) return Boolean(actual && matchesFileIdentity(actual, expected)) }) ) { @@ -273,3 +297,32 @@ export function matchingKnownSnapshot( } return null } + +/** + * The observed folder hashed as if it held only the files the current bundle owns. + * + * Compared against the updater lock's `skillFolderHash` ALONGSIDE the whole-folder + * hash, never instead of it. The two cover different halves and neither subsumes the + * other: the lock records the source tree, so a folder carrying a sidecar only ever + * matches scoped, while an upstream revision that ADDS a file puts that file in the + * lock's own tree and only ever matches whole. Publishing one and dropping the other + * would trade this bug for #11220 — a clean install reading "may be modified" and its + * update run reporting failure. + * + * Scoped to the CURRENT entry rather than every revision ever shipped: a leftover from + * an older revision is exactly the stale byte the lock comparison should look past, and + * unioning historical paths would drag it back in on the accident of its name. + * + * A folder holding none of them yields the whole-folder hash rather than git's empty-tree + * sha, which is a real, matchable value: a lock that ever recorded an empty source tree + * would otherwise vouch for every such folder. + */ +export function officialPathsGitTreeSha( + observed: ObservedSkillPackage, + officialPaths: ReadonlySet +): string { + const scoped = observed.treeEntries.filter((entry) => officialPaths.has(entry.path)) + return scoped.length === 0 || scoped.length === observed.treeEntries.length + ? observed.observedGitTreeSha + : skillPackageGitTreeSha(scoped) +} diff --git a/src/main/skills/skill-update-convergence.test.ts b/src/main/skills/skill-update-convergence.test.ts index 86e4f395e6d..3e8d2633a06 100644 --- a/src/main/skills/skill-update-convergence.test.ts +++ b/src/main/skills/skill-update-convergence.test.ts @@ -2,13 +2,22 @@ import { describe, expect, it } from 'vitest' import type { SkillFreshnessInstallation, SkillKnownSnapshot } from '../../shared/skill-freshness' import { convergableSkillNames } from './skill-update-convergence' +// `installedReleaseRevision` is the revision observation resolved this copy to, and +// null is the honest "readable, but matching nothing we know". A null +// `observedPackageDigest` is the different case of a copy that could not be read. +// +// The digest defaults to the resolved revision's own, so these fixtures describe a +// folder holding nothing but official files. That keeps the revert signal honest: +// swapping resolution back to a digest lookup still passes every case below except +// the sidecar one, which is the only case that distinguishes the two mechanisms. function placement( name: string, - observedPackageDigest: string | null, - topology: SkillFreshnessInstallation['topology'] = 'canonical-copy' + installedReleaseRevision: number | null, + topology: SkillFreshnessInstallation['topology'] = 'canonical-copy', + observedPackageDigest: string | null = `digest-${installedReleaseRevision}` ): SkillFreshnessInstallation { return { - id: `${name}:${observedPackageDigest}:${topology}`, + id: `${name}:${installedReleaseRevision}:${topology}`, name, rootId: 'home', providers: [], @@ -19,7 +28,7 @@ function placement( physicalIdentity: '1:1', topology, status: 'outdated', - installedReleaseRevision: null, + installedReleaseRevision, installedAppVersion: null, currentReleaseRevision: 8, currentPackageDigest: 'digest-current', @@ -29,24 +38,22 @@ function placement( } } -function revision(packageDigest: string, gitTreeSha: string): SkillKnownSnapshot { - return { releaseRevision: 1, packageDigest, gitTreeSha, files: [] } +function revision(releaseRevision: number, gitTreeSha: string): SkillKnownSnapshot { + return { releaseRevision, packageDigest: `digest-${releaseRevision}`, gitTreeSha, files: [] } } +const PRE_STUB = revision(1, 'f3727995') +const STUB = revision(2, '091d9bcc') + describe('convergableSkillNames', () => { // The real reported case: the lock records the stub tree (091d9bcc) while disk // still holds the pre-stub revision (f3727995). `skills update` compares lock to // source, sees no work, exits 0 and writes nothing — forever. it('drops a skill whose lock records a revision the disk does not have', () => { const result = convergableSkillNames( - [placement('orca-linear', 'digest-pre-stub')], + [placement('orca-linear', 1)], new Map([['orca-linear', '091d9bcc']]), - { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ] - } + { 'orca-linear': [PRE_STUB, STUB] } ) expect([...result]).toEqual([]) }) @@ -55,42 +62,50 @@ describe('convergableSkillNames', () => { // has simply moved ahead of what this build bundles. The update really can converge. it('keeps a skill whose lock matches disk even when it is outdated', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-installed')], + [placement('orca-cli', 1)], new Map([['orca-cli', 'aaaa1111']]), - { 'orca-cli': [revision('digest-installed', 'aaaa1111')] } + { 'orca-cli': [revision(1, 'aaaa1111')] } ) expect([...result]).toEqual(['orca-cli']) }) it('keeps a skill whose disk content matches no known revision', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-unknown')], + [placement('orca-cli', null)], new Map([['orca-cli', 'aaaa1111']]), - { 'orca-cli': [revision('digest-other', 'bbbb2222')] } + { 'orca-cli': [revision(1, 'bbbb2222')] } ) expect([...result]).toEqual(['orca-cli']) }) it('keeps a skill with no observable placement', () => { const result = convergableSkillNames( - [placement('orca-cli', null)], + [placement('orca-cli', null, 'canonical-copy', null)], new Map([['orca-cli', 'aaaa1111']]), - { 'orca-cli': [revision('digest-installed', 'aaaa1111')] } + { 'orca-cli': [revision(1, 'aaaa1111')] } ) expect([...result]).toEqual(['orca-cli']) }) + // Why: a sidecar an agent CLI dropped beside the official files makes the folder + // digest match no revision at all, so resolving disk content by that digest would + // read as unidentifiable and quietly re-arm the unwinnable update. Observation + // already placed this copy at the pre-stub revision; the gate must honour that. + it('drops a stale skill whose folder holds files no revision lists', () => { + const result = convergableSkillNames( + [placement('orca-linear', 1, 'canonical-copy', 'digest-with-sidecar')], + new Map([['orca-linear', '091d9bcc']]), + { 'orca-linear': [PRE_STUB, STUB] } + ) + expect([...result]).toEqual([]) + }) + // One placement still matching the lock means the command has an anchor to write. it('keeps a skill when any placement still matches the lock', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-installed'), placement('orca-cli', 'digest-pre-stub')], + [placement('orca-cli', 1), placement('orca-cli', 2)], new Map([['orca-cli', 'aaaa1111']]), - { - 'orca-cli': [ - revision('digest-installed', 'aaaa1111'), - revision('digest-pre-stub', 'f3727995') - ] - } + { 'orca-cli': [revision(1, 'aaaa1111'), revision(2, 'f3727995')] } ) expect([...result]).toEqual(['orca-cli']) }) @@ -98,23 +113,21 @@ describe('convergableSkillNames', () => { // A lock hash we cannot place is not evidence the command is stuck. it('keeps a skill whose lock names no revision we know', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-pre-stub')], + [placement('orca-cli', 1)], new Map([['orca-cli', 'not-a-known-tree']]), - { 'orca-cli': [revision('digest-pre-stub', 'f3727995')] } + { 'orca-cli': [revision(1, 'f3727995')] } ) expect([...result]).toEqual(['orca-cli']) }) - // Why: `diskTreeShas` silently drops digests that match no known revision, so a + // Why: `diskTreeShas` silently drops placements that resolved to nothing, so a // stale copy sitting beside an unidentifiable one must NOT gate the name — the // unknown half could be anything, including a copy the command would converge. it('keeps a skill when one placement is stale but another is unidentifiable', () => { const result = convergableSkillNames( - [placement('orca-cli', 'digest-pre-stub'), placement('orca-cli', 'digest-unknown')], + [placement('orca-cli', 1), placement('orca-cli', null)], new Map([['orca-cli', '091d9bcc']]), - { - 'orca-cli': [revision('digest-pre-stub', 'f3727995'), revision('digest-stub', '091d9bcc')] - } + { 'orca-cli': [PRE_STUB, STUB] } ) expect([...result]).toEqual(['orca-cli']) }) @@ -124,17 +137,9 @@ describe('convergableSkillNames', () => { // placement and re-arm the unwinnable update on the drifted canonical. it('ignores an unidentifiable plugin-cache copy when judging the canonical', () => { const result = convergableSkillNames( - [ - placement('orca-linear', 'digest-pre-stub'), - placement('orca-linear', 'digest-cache-repack', 'plugin-cache') - ], + [placement('orca-linear', 1), placement('orca-linear', null, 'plugin-cache')], new Map([['orca-linear', '091d9bcc']]), - { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ] - } + { 'orca-linear': [PRE_STUB, STUB] } ) expect([...result]).toEqual([]) }) @@ -143,34 +148,23 @@ describe('convergableSkillNames', () => { // the command only writes the canonical, which is still drifted. it('ignores a plugin-cache copy that matches the lock', () => { const result = convergableSkillNames( - [ - placement('orca-linear', 'digest-pre-stub'), - placement('orca-linear', 'digest-stub', 'plugin-cache') - ], + [placement('orca-linear', 1), placement('orca-linear', 2, 'plugin-cache')], new Map([['orca-linear', '091d9bcc']]), - { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ] - } + { 'orca-linear': [PRE_STUB, STUB] } ) expect([...result]).toEqual([]) }) it('judges each locked skill independently', () => { const result = convergableSkillNames( - [placement('orca-linear', 'digest-pre-stub'), placement('orca-cli', 'digest-installed')], + [placement('orca-linear', 1), placement('orca-cli', 1)], new Map([ ['orca-linear', '091d9bcc'], ['orca-cli', 'aaaa1111'] ]), { - 'orca-linear': [ - revision('digest-pre-stub', 'f3727995'), - revision('digest-stub', '091d9bcc') - ], - 'orca-cli': [revision('digest-installed', 'aaaa1111')] + 'orca-linear': [PRE_STUB, STUB], + 'orca-cli': [revision(1, 'aaaa1111')] } ) expect([...result]).toEqual(['orca-cli']) diff --git a/src/main/skills/skill-update-convergence.ts b/src/main/skills/skill-update-convergence.ts index 41889058f3f..c318e89d8b8 100644 --- a/src/main/skills/skill-update-convergence.ts +++ b/src/main/skills/skill-update-convergence.ts @@ -34,20 +34,27 @@ export function convergableSkillNames( // it must neither gate the name nor rescue it — an unidentifiable cache copy // (or one parked at the lock's own revision) would otherwise defeat the gate // and re-arm the unwinnable update. - const digests = installations - .filter( - (entry) => - entry.name === name && - SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) && - entry.observedPackageDigest - ) - .map((entry) => entry.observedPackageDigest) - if (digests.length === 0) { + const observable = installations.filter( + (entry) => + entry.name === name && + SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) && + entry.observedPackageDigest + ) + if (observable.length === 0) { continue } const revisions = knownSnapshots[name] ?? [] - const diskTreeShas = digests - .map((digest) => revisions.find((revision) => revision.packageDigest === digest)?.gitTreeSha) + // Why: the revision each placement resolved to during observation, not a fresh + // lookup by whole-folder digest. Identity tolerates files the manifest never + // listed, so a folder holding an agent CLI's sidecar digests to nothing any + // revision knows — re-deriving here would call every such placement + // unidentifiable and quietly retire the gate for the users who most need it. + const diskTreeShas = observable + .map( + (entry) => + revisions.find((revision) => revision.releaseRevision === entry.installedReleaseRevision) + ?.gitTreeSha + ) .filter((sha): sha is string => Boolean(sha)) // Why: only claim the lock is stale when BOTH sides are positively identified — // the lock names a revision we know, and every placement resolves to a different @@ -55,11 +62,11 @@ export function convergableSkillNames( // revision older than the registry) is not evidence of anything, and gating on it // would withhold updates that would have worked. const lockNamesAKnownRevision = revisions.some((entry) => entry.gitTreeSha === lockHash) - // `diskTreeShas` drops digests that match no known revision, so requiring every - // observed digest to resolve is what keeps `every` honest: without it, one stale - // copy beside one unidentifiable copy would gate the name off the resolved half - // alone, contradicting the unknown-stays-eligible rule above. - const everyPlacementResolved = diskTreeShas.length === digests.length + // `diskTreeShas` drops placements that resolved to no known revision, so requiring + // every observable one to resolve is what keeps `every` honest: without it, one + // stale copy beside one unidentifiable copy would gate the name off the resolved + // half alone, contradicting the unknown-stays-eligible rule above. + const everyPlacementResolved = diskTreeShas.length === observable.length if ( lockNamesAKnownRevision && everyPlacementResolved && diff --git a/src/main/skills/skill-update-outcome.ts b/src/main/skills/skill-update-outcome.ts index 9a15b856644..9c032cb9f2f 100644 --- a/src/main/skills/skill-update-outcome.ts +++ b/src/main/skills/skill-update-outcome.ts @@ -2,6 +2,7 @@ import { SUPPORTED_GLOBAL_SKILL_TOPOLOGIES, type SkillFreshnessInstallation } from '../../shared/skill-freshness' +import { matchesUpdaterLock } from './skill-update-registration' /** * Names that did not land, judged from the post-run inventory. @@ -53,9 +54,5 @@ function skillPlacementLanded( // yet. Everything else unrecognized (half-written, no lock entry, mismatch) // still fails, and `outdated` is never forgiven: lock == disk there means the // command provably wrote nothing. - return ( - entry.status === 'unrecognized' && - lockHash !== undefined && - entry.observedGitTreeSha === lockHash - ) + return entry.status === 'unrecognized' && matchesUpdaterLock(entry, lockHash) } diff --git a/src/main/skills/skill-update-registration.ts b/src/main/skills/skill-update-registration.ts index 66231d71fbe..e80b8dc0580 100644 --- a/src/main/skills/skill-update-registration.ts +++ b/src/main/skills/skill-update-registration.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' +import type { SkillFreshnessInstallation } from '../../shared/skill-freshness' const GLOBAL_SKILL_LOCK_SCHEMA_VERSION = 3 @@ -21,6 +22,32 @@ function globalSkillLockPath(args: SkillUpdateRegistrationArgs): string { : join(args.homeDir ?? homedir(), '.agents', '.skill-lock.json') } +/** + * Whether a placement holds exactly the bytes the updater recorded installing. + * + * Either hash may carry it. The lock is the git tree sha of the source folder, so a + * folder an agent CLI dropped a sidecar into only matches once that sidecar is scoped + * out, while an upstream revision that added a file only matches whole — that file is + * in the lock's own tree but in no bundled snapshot. Requiring one specific hash trades + * either #12694 or #11220 for the other. + * + * Not exhaustive, and deliberately so: a folder carrying BOTH a sidecar and an upstream + * file no bundle lists matches neither hash and stays unrecognized, exactly as it does + * without this pair. Closing that needs the sidecar gone before hashing — an observe-time + * exclusion like `isOsMetadataSkillEntryName`, which means naming the foreign paths rather + * than tolerating any unlisted one. Editing a listed file still matches neither hash. + */ +export function matchesUpdaterLock( + installation: SkillFreshnessInstallation, + lockHash: string | undefined +): boolean { + return ( + lockHash !== undefined && + (installation.observedGitTreeSha === lockHash || + installation.observedOfficialGitTreeSha === lockHash) + ) +} + export async function readGloballyUpdatableSkillNames( args: SkillUpdateRegistrationArgs = {} ): Promise> { diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts index 24bbcc9df38..dafc504727d 100644 --- a/src/shared/skill-freshness.ts +++ b/src/shared/skill-freshness.ts @@ -83,6 +83,13 @@ export type SkillFreshnessInstallation = { observedPackageDigest: string | null /** Git tree sha of the observed bytes; lets the post-run verdict match disk against the updater's lock. */ observedGitTreeSha?: string | null + /** + * The same hash over only the files the current bundle lists. Carried beside the + * whole-folder hash, not in place of it, so a folder holding an agent CLI's sidecar + * can still match the lock without blinding the check to an upstream revision that + * added a file. Absent from hosts older than this field. + */ + observedOfficialGitTreeSha?: string | null errorCategory: string | null } From a30e3b9f61e51e78b0afea555b94619f87ed0592 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:57:16 -0700 Subject: [PATCH 37/46] feat(dashboard): add experimental agent map view (#12168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): add experimental agent map view * fix(dashboard): harden agent map behavior * fix(dashboard): harden agent map recovery * fix(dashboard): close map selection on view change * fix(agent-map): center sparse layouts * fix(agent-map): align completion and workspace actions * Polish agent map interactions and repo labels * feat(agent-map): add worktree lineage and project actions * fix(agent-map): use marker for unread agents * fix(agent-map): compact orchestrated families * fix(dashboard): harden agent map actions and layout * fix(agent-map): bound layout work and preserve interactions * fix(agent-map): move unread marker to ring top-right * fix(agent-map): seat unread marker on the ring's top-left edge * feat(agent-map): restore the agent launcher and declutter map labels Three gaps in the experimental Agent Map: - The "start a new agent" picker was split onto a preserved branch during the 08-02 rebase (47829cb226) and never re-landed. Restores that commit and its pop-out IPC, keyed on the raw worktree id rather than the map identity. - Workspace labels draw at a fixed screen size with no collision handling, so a zoomed-out map stacked dozens of names on each other. Adds a declutter pass that seats project names first, then workspace names by attention, then project counts in whatever room is left. - The pop-out had no workspace right-click at all: its renderer has no store, so the shared sidebar menu cannot mount there. Adds a snapshot-driven menu with the launcher and Sleep, relayed to the main renderer. * refactor(agent-map): fold the map's filter rail into the shared toolbar filter The rail duplicated the toolbar's project filter and cost the canvas 14rem of width on the surface that needs it most. Agent states move into the toolbar's Filter dropdown (map view only — the board's columns already separate them) and count toward its badge; project filtering falls back to the toolbar's own. Show all is the dropdown's Clear all, and Fit already lives in the viewport controls. * fix(agent-map): isolate map work from main renderer * perf(agent-map): stream status updates to popout * fix(i18n): add agent map catalog entries * feat(agent-map): glow working entities * fix(agent-map): prioritize attention ring status * fix(agent-map): distinguish subagent connectors --- src/main/index.ts | 33 +- .../ipc/dashboard-agent-launch-validation.ts | 40 + .../dashboard-filter-payload-validation.ts | 44 + .../ipc/dashboard-payload-validation.test.ts | 119 +++ src/main/ipc/dashboard-payload-validation.ts | 78 +- src/main/ipc/dashboard-popout.test.ts | 34 + src/main/ipc/dashboard-popout.ts | 35 +- .../dashboard-workspace-payload-validation.ts | 80 ++ .../window/dashboard-popout-window.test.ts | 16 + src/main/window/dashboard-popout-window.ts | 11 +- src/preload/api-types.ts | 14 +- src/preload/index.ts | 36 +- src/renderer/src/App.tsx | 9 +- .../AgentDashboardFilterChips.tsx | 13 + .../AgentDashboardMapView.tsx | 129 +++ .../AgentDashboardToolbar.tsx | 112 ++- .../AgentKanbanBoard.test.tsx | 252 +++++- .../dashboard-popout/AgentKanbanBoard.tsx | 216 ++++- .../dashboard-popout/AgentKanbanCard.test.tsx | 6 +- .../dashboard-popout/AgentKanbanCard.tsx | 10 +- .../dashboard-popout/AgentMap.test.tsx | 752 ++++++++++++++++++ .../components/dashboard-popout/AgentMap.tsx | 182 +++++ .../AgentMapCanvas.performance.test.tsx | 95 +++ .../dashboard-popout/AgentMapCanvas.tsx | 415 ++++++++++ .../AgentMapProjectContextMenu.tsx | 114 +++ .../AgentMapProjectContextMenuLoader.tsx | 29 + .../AgentMapProjectLabel.test.tsx | 60 ++ .../dashboard-popout/AgentMapScene.tsx | 243 ++++++ .../AgentMapSnapshotWorkspaceMenu.tsx | 100 +++ .../AgentMapViewportControls.tsx | 47 ++ ...ntMapWorkspaceContextMenu.boundary.test.ts | 35 + .../AgentMapWorkspaceContextMenu.test.tsx | 375 +++++++++ .../AgentMapWorkspaceContextMenu.tsx | 167 ++++ .../AgentMapWorkspaceContextMenuLoader.tsx | 35 + .../AgentMapWorktreeLabel.tsx | 39 + .../AgentMapWorktreeRingNode.tsx | 370 +++++++++ .../AgentTerminalDialog.test.tsx | 87 +- .../dashboard-popout/AgentTerminalDialog.tsx | 195 +++-- .../dashboard-popout/AgentTerminalPreview.tsx | 9 +- .../dashboard-popout/DashboardPopoutRoot.tsx | 12 +- .../dashboard-popout/agent-board-filtering.ts | 45 +- .../agent-map-agent-placement.ts | 54 ++ .../dashboard-popout/agent-map-filter.test.ts | 77 ++ .../dashboard-popout/agent-map-filter.ts | 48 ++ .../agent-map-glow.performance.test.ts | 32 + .../agent-map-label-declutter.test.ts | 164 ++++ .../agent-map-label-declutter.ts | 273 +++++++ .../agent-map-layout-metadata.ts | 53 ++ .../dashboard-popout/agent-map-layout.test.ts | 604 ++++++++++++++ .../dashboard-popout/agent-map-layout.ts | 308 +++++++ .../agent-map-lineage-layout.ts | 224 ++++++ .../agent-map-navigation.test.ts | 42 + .../dashboard-popout/agent-map-navigation.ts | 53 ++ .../agent-map-node-metadata.ts | 17 + .../agent-map-packing-spatial-index.ts | 94 +++ .../agent-map-project-placement.ts | 50 ++ .../agent-map-spawn-clustering.ts | 27 + .../agent-map-viewport-transition.ts | 56 ++ .../agent-map-workspace-identity.ts | 29 + .../agent-map-workspace-visibility.test.ts | 81 ++ .../agent-map-workspace-visibility.ts | 25 + .../agent-map-worktree-active-status.test.ts | 21 + .../agent-map-worktree-active-status.ts | 15 + .../agent-map-worktree-lineage-layout.test.ts | 223 ++++++ .../agent-map-worktree-lineage-layout.ts | 279 +++++++ .../agent-map-worktree-packing.test.ts | 168 ++++ .../agent-map-worktree-packing.ts | 267 +++++++ .../components/dashboard-popout/agent-map.css | 335 ++++++++ .../dashboard-agent-status-patch.test.ts | 111 +++ .../dashboard-agent-status-patch.ts | 83 ++ .../dashboard-popout/useAgentMapCanvasSize.ts | 35 + .../useAgentMapContextMenus.tsx | 156 ++++ .../useAgentMapSelectedFocus.ts | 63 ++ .../useAgentMapStateFilter.ts | 29 + .../useAgentMapViewportTransition.ts | 51 ++ .../useDashboardSnapshot.test.tsx | 100 ++- .../dashboard-popout/useDashboardSnapshot.ts | 96 ++- .../dashboard/AgentDashboardDrawer.test.tsx | 137 ++++ .../dashboard/AgentDashboardDrawer.tsx | 11 +- .../dashboard/DashboardPopoutBridge.tsx | 7 + ...nt-dashboard-performance-isolation.test.ts | 36 + .../components/dashboard/agent-row-lineage.ts | 10 + ...ashboard-snapshot-folder-workspace.test.ts | 144 ++++ .../build-dashboard-snapshot.test.ts | 210 ++++- .../dashboard/build-dashboard-snapshot.ts | 185 ++--- .../dashboard/dashboard-card-bucket.ts | 15 + .../dashboard/dashboard-card-context.ts | 6 +- .../dashboard/dashboard-card-labels.ts | 43 + .../dashboard-snapshot-filter-options.ts | 27 + .../dashboard-snapshot-workspaces.ts | 124 +++ .../dashboard-worktree-launch-options.test.ts | 132 +++ .../dashboard-worktree-launch-options.ts | 127 +++ .../dashboard/launch-dashboard-agent.test.ts | 46 ++ .../dashboard/launch-dashboard-agent.ts | 23 + .../dashboard/useAgentBucketCounts.test.tsx | 85 ++ .../dashboard/useAgentBucketCounts.ts | 10 +- .../components/dashboard/useDashboardData.ts | 1 + .../useDashboardPopoutBridge.test.tsx | 59 +- .../dashboard/useDashboardPopoutBridge.ts | 30 +- .../dashboard/useLiveDashboardSnapshot.ts | 9 + .../settings/ExperimentalPane.test.tsx | 2 +- .../sidebar/AgentDashboardSidebarEntry.tsx | 90 +++ .../AgentDashboardSidebarHost.test.tsx | 75 ++ .../sidebar/AgentDashboardSidebarHost.tsx | 43 + .../src/components/sidebar/Sidebar.test.tsx | 59 -- .../components/sidebar/SidebarNav.test.tsx | 14 +- .../src/components/sidebar/SidebarNav.tsx | 124 +-- .../sidebar/WorktreeContextMenu.tsx | 41 +- src/renderer/src/components/sidebar/index.tsx | 39 +- src/renderer/src/i18n/locales/en.json | 41 + src/renderer/src/i18n/locales/es.json | 25 +- src/renderer/src/i18n/locales/ja.json | 25 +- src/renderer/src/i18n/locales/ko.json | 25 +- src/renderer/src/i18n/locales/zh.json | 25 +- src/renderer/src/store/selectors.ts | 15 +- src/shared/constants.test.ts | 4 +- src/shared/constants.ts | 4 - src/shared/dashboard-snapshot.ts | 67 ++ 118 files changed, 10715 insertions(+), 516 deletions(-) create mode 100644 src/main/ipc/dashboard-agent-launch-validation.ts create mode 100644 src/main/ipc/dashboard-filter-payload-validation.ts create mode 100644 src/main/ipc/dashboard-workspace-payload-validation.ts create mode 100644 src/renderer/src/components/dashboard-popout/AgentDashboardMapView.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMap.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMap.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapScene.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx create mode 100644 src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-filter.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-layout.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-navigation.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts create mode 100644 src/renderer/src/components/dashboard-popout/agent-map.css create mode 100644 src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts create mode 100644 src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts create mode 100644 src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts create mode 100644 src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx create mode 100644 src/renderer/src/components/dashboard/DashboardPopoutBridge.tsx create mode 100644 src/renderer/src/components/dashboard/agent-dashboard-performance-isolation.test.ts create mode 100644 src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-card-bucket.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-card-labels.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts create mode 100644 src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts create mode 100644 src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts create mode 100644 src/renderer/src/components/dashboard/launch-dashboard-agent.ts create mode 100644 src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx create mode 100644 src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx create mode 100644 src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx create mode 100644 src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx diff --git a/src/main/index.ts b/src/main/index.ts index ebabeb2b18e..790a8a2ed69 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -188,7 +188,10 @@ import { ensureAutoUpdaterConfigured } from './window/attach-main-window-services' import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { zoomDashboardPopoutIfFocused } from './window/dashboard-popout-window' +import { + getDashboardPopoutWindow, + zoomDashboardPopoutIfFocused +} from './window/dashboard-popout-window' import { createSystemTray, destroySystemTray, @@ -303,6 +306,7 @@ import { } from '../shared/synthetic-agent-title' import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' +import { isAskUserQuestionTool } from '../shared/agent-question-answered-intent' import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import { HEADLESS_RUNTIME_WINDOW_ID, @@ -1450,7 +1454,16 @@ function openMainWindow(): BrowserWindow { maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay }) const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) - mainWindow?.webContents.send('agentStatus:set', { + const suppressSyntheticCodexAutoApprovalTitle = + payload.agentType === 'codex' && + (payload.state === 'waiting' || payload.state === 'blocked') + ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ + agentType: payload.agentType, + state: payload.state, + launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) + }) + : false + const statusEvent = { ...payload, paneKey, ...(launchToken ? { launchToken } : {}), @@ -1464,19 +1477,14 @@ function openMainWindow(): BrowserWindow { ...(promptInteractionKey ? { promptInteractionKey } : {}), ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), ...(orchestration ? { orchestration } : {}) - }) + } + mainWindow?.webContents.send('agentStatus:set', statusEvent) + if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { + getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) + } recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) // Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync. const profile = getSyntheticAgentTitleProfile(payload.agentType) - const suppressSyntheticCodexAutoApprovalTitle = - payload.agentType === 'codex' && - (payload.state === 'waiting' || payload.state === 'blocked') - ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ - agentType: payload.agentType, - state: payload.state, - launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) - }) - : false if ( profile && shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && @@ -1491,6 +1499,7 @@ function openMainWindow(): BrowserWindow { return } mainWindow?.webContents.send('agentStatus:clear', clear) + getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear) }) setMigrationUnsupportedPtyListener((event) => { if (mainWindow?.isDestroyed()) { diff --git a/src/main/ipc/dashboard-agent-launch-validation.ts b/src/main/ipc/dashboard-agent-launch-validation.ts new file mode 100644 index 00000000000..c50e72f6ec3 --- /dev/null +++ b/src/main/ipc/dashboard-agent-launch-validation.ts @@ -0,0 +1,40 @@ +import { + DASHBOARD_MAX_LAUNCH_WORKTREES, + type DashboardSpawnAgentArgs +} from '../../shared/dashboard-snapshot' +import { isTuiAgent } from '../../shared/tui-agent-config' + +const MAX_ID_LENGTH = 4_096 +const MAX_AGENTS_PER_WORKTREE = 64 + +function isBoundedId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID_LENGTH +} + +export function isDashboardSpawnAgentArgs(value: unknown): value is DashboardSpawnAgentArgs { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const args = value as Record + return isBoundedId(args.worktreeId) && isTuiAgent(args.agent) +} + +export function isDashboardLaunchOptions(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const entries = Object.entries(value as Record) + return ( + entries.length <= DASHBOARD_MAX_LAUNCH_WORKTREES && + entries.every( + ([worktreeId, agents]) => + isBoundedId(worktreeId) && + Array.isArray(agents) && + agents.length <= MAX_AGENTS_PER_WORKTREE && + agents.every(isTuiAgent) + ) + ) +} diff --git a/src/main/ipc/dashboard-filter-payload-validation.ts b/src/main/ipc/dashboard-filter-payload-validation.ts new file mode 100644 index 00000000000..694d2db2717 --- /dev/null +++ b/src/main/ipc/dashboard-filter-payload-validation.ts @@ -0,0 +1,44 @@ +import { DASHBOARD_MAX_LABEL_LENGTH } from '../../shared/dashboard-snapshot' + +const MAX_DASHBOARD_FILTER_OPTIONS = 500 +const MAX_ID_LENGTH = 4_096 + +function isBoundedString(value: unknown, maxLength: number, allowEmpty = false): value is string { + return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0) +} + +function isOptionalBoundedString(value: unknown, maxLength: number): boolean { + return value === undefined || isBoundedString(value, maxLength, true) +} + +function isDashboardFilterOptionList(value: unknown): boolean { + return ( + Array.isArray(value) && + value.length <= MAX_DASHBOARD_FILTER_OPTIONS && + value.every((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + return false + } + const option = entry as Record + return ( + isBoundedString(option.id, MAX_ID_LENGTH) && + isBoundedString(option.label, DASHBOARD_MAX_LABEL_LENGTH, true) && + isOptionalBoundedString(option.color, MAX_ID_LENGTH) + ) + }) + ) +} + +export function isDashboardFilterOptions(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const options = value as Record + return ( + isDashboardFilterOptionList(options.projects) && + isDashboardFilterOptionList(options.workspaceStatuses) + ) +} diff --git a/src/main/ipc/dashboard-payload-validation.test.ts b/src/main/ipc/dashboard-payload-validation.test.ts index 954723a6182..f377ad3c9de 100644 --- a/src/main/ipc/dashboard-payload-validation.test.ts +++ b/src/main/ipc/dashboard-payload-validation.test.ts @@ -19,6 +19,7 @@ vi.mock('../../shared/repo-icon', async (importOriginal) => { import { admitDashboardSnapshot, isDashboardRevealAgentArgs, + isDashboardSpawnAgentArgs, isDashboardSnapshot } from './dashboard-payload-validation' @@ -38,8 +39,13 @@ const SNAPSHOT = { worktreeId: 'worktree-1', tabId: 'tab-1', leafId: 'leaf-1', + parentPaneKey: 'tab-parent:leaf-parent', + parentWorktreeId: 'parent-worktree-1', repoName: 'Orca', worktreeName: 'Dashboard', + hostKind: 'ssh', + executionHostId: 'ssh:build-box', + workspaceKind: 'worktree', workspaceStatusId: 'in-review', workspaceStatusLabel: 'In review', workspaceStatusColor: 'emerald', @@ -53,6 +59,23 @@ const SNAPSHOT = { askSummary: '{"question":"Proceed?"}' } ], + workspaces: [ + { + repoId: 'repo-1', + worktreeId: 'worktree-1', + repoName: 'Orca', + worktreeName: 'Dashboard', + parentWorktreeId: 'parent-worktree-1', + hostKind: 'ssh', + executionHostId: 'ssh:build-box', + workspaceKind: 'worktree', + workspaceStatusId: 'in-review', + workspaceStatusLabel: 'In review', + workspaceStatusColor: 'emerald', + hasReview: true, + review: { number: 11012, state: 'open' } + } + ], showIdle: false, filterOptions: { projects: [{ id: 'repo-1', label: 'Orca' }], @@ -102,6 +125,42 @@ describe('dashboard payload validation', () => { cards: [{ ...SNAPSHOT.cards[0], subagents: [{ id: '', name: 'bad', dotState: 'idle' }] }] }) ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], hostKind: 'satellite' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], executionHostId: 'build-box' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], executionHostId: `ssh:${'x'.repeat(4_097)}` }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], parentWorktreeId: 'x'.repeat(4_097) }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], workspaceKind: 'repository' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + cards: [{ ...SNAPSHOT.cards[0], parentPaneKey: 'x'.repeat(4_097) }] + }) + ).toBe(false) }) it('accepts repo icons a pop-out can safely render, and rejects the rest', () => { @@ -162,6 +221,43 @@ describe('dashboard payload validation', () => { ).toBe(false) }) + it('validates bounded map workspace metadata independently of cards', () => { + expect(isDashboardSnapshot({ ...SNAPSHOT, cards: [] })).toBe(true) + expect(isDashboardSnapshot({ ...SNAPSHOT, workspaces: undefined })).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [{ ...SNAPSHOT.workspaces[0], executionHostId: 'build-box' }] + }) + ).toBe(false) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [{ ...SNAPSHOT.workspaces[0], worktreeName: 'x'.repeat(1_025) }] + }) + ).toBe(false) + }) + + it('validates bounded launch choices and spawn requests', () => { + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + launchableAgentsByWorktreeId: { 'worktree-1': ['codex', 'claude'] } + }) + ).toBe(true) + expect( + isDashboardSnapshot({ + ...SNAPSHOT, + launchableAgentsByWorktreeId: { 'worktree-1': ['not-an-agent'] } + }) + ).toBe(false) + expect(isDashboardSnapshot({ ...SNAPSHOT, launchableAgentsByWorktreeId: [] })).toBe(false) + + expect(isDashboardSpawnAgentArgs({ worktreeId: 'worktree-1', agent: 'codex' })).toBe(true) + expect(isDashboardSpawnAgentArgs({ worktreeId: '', agent: 'codex' })).toBe(false) + expect(isDashboardSpawnAgentArgs({ worktreeId: 'worktree-1', agent: 'unknown' })).toBe(false) + }) + it('bounds the conversation name', () => { expect( isDashboardSnapshot({ @@ -241,6 +337,19 @@ describe('dashboard payload validation', () => { expect(admitted?.snapshot.cards.map((card) => card.paneKey)).toEqual(['tab-1:leaf-1']) }) + it('drops only malformed optional workspace metadata', () => { + const admitted = admitDashboardSnapshot({ + ...SNAPSHOT, + workspaces: [ + SNAPSHOT.workspaces[0], + { ...SNAPSHOT.workspaces[0], worktreeId: '', worktreeName: 'Invalid' } + ] + }) + + expect(admitted?.droppedCardCount).toBe(0) + expect(admitted?.snapshot.workspaces).toEqual([SNAPSHOT.workspaces[0]]) + }) + it('reports nothing dropped for a fully valid snapshot', () => { const admitted = admitDashboardSnapshot(SNAPSHOT) @@ -406,10 +515,20 @@ describe('dashboard payload validation', () => { isDashboardRevealAgentArgs({ repoId: 'repo-1', worktreeId: 'worktree-1', + executionHostId: 'runtime:env-1', tabId: 'tab-1', leafId: null }) ).toBe(true) + expect( + isDashboardRevealAgentArgs({ + repoId: 'repo-1', + worktreeId: 'worktree-1', + executionHostId: 'runtime:', + tabId: 'tab-1', + leafId: null + }) + ).toBe(false) expect( isDashboardRevealAgentArgs({ repoId: 'repo-1', worktreeId: 'worktree-1', tabId: '' }) ).toBe(false) diff --git a/src/main/ipc/dashboard-payload-validation.ts b/src/main/ipc/dashboard-payload-validation.ts index 0dfb80648d9..f1cc0b66b7c 100644 --- a/src/main/ipc/dashboard-payload-validation.ts +++ b/src/main/ipc/dashboard-payload-validation.ts @@ -1,9 +1,11 @@ import { DASHBOARD_MAX_LABEL_LENGTH, type DashboardRevealAgentArgs, + type DashboardSleepWorkspaceArgs, type DashboardSnapshot } from '../../shared/dashboard-snapshot' import { BoundedMap } from '../../shared/bounded-map' +import { normalizeExecutionHostId } from '../../shared/execution-host' import { sanitizeRepoIcon } from '../../shared/repo-icon' import { AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH, @@ -11,11 +13,17 @@ import { AGENT_STATUS_MAX_FIELD_LENGTH, AGENT_TYPE_MAX_LENGTH } from '../../shared/agent-status-types' +import { isDashboardLaunchOptions } from './dashboard-agent-launch-validation' +import { + admitDashboardWorkspaces, + isDashboardWorkspaceList +} from './dashboard-workspace-payload-validation' +import { isDashboardFilterOptions } from './dashboard-filter-payload-validation' +export { isDashboardSpawnAgentArgs } from './dashboard-agent-launch-validation' const MAX_DASHBOARD_CARDS = 1_000 const MAX_DASHBOARD_SUBAGENTS = 100 const MAX_DASHBOARD_REPO_ICONS = 500 -const MAX_DASHBOARD_FILTER_OPTIONS = 500 // Why: sanitizing an image icon base64-decodes the whole data URI to read a // 24-byte header, and the renderer republishes the same icons every 250 ms. const MAX_CACHED_ICON_SRC_BYTES = 8 * 1024 * 1024 @@ -28,6 +36,8 @@ const MAX_ID_LENGTH = 4_096 const MAX_LABEL_LENGTH = DASHBOARD_MAX_LABEL_LENGTH const DASHBOARD_BUCKETS = new Set(['attention', 'working', 'done', 'idle']) const DASHBOARD_DOT_STATES = new Set(['working', 'blocked', 'waiting', 'done', 'idle']) +const DASHBOARD_HOST_KINDS = new Set(['local', 'ssh', 'wsl', 'remote']) +const DASHBOARD_WORKSPACE_KINDS = new Set(['worktree', 'folder']) const DASHBOARD_REVIEW_STATES = new Set(['open', 'closed', 'merged', 'draft']) const DASHBOARD_HOST_PLATFORMS = new Set([ 'aix', @@ -64,6 +74,9 @@ export function isDashboardRevealAgentArgs(value: unknown): value is DashboardRe return ( isBoundedString(args.repoId, MAX_ID_LENGTH) && isBoundedString(args.worktreeId, MAX_ID_LENGTH) && + (args.executionHostId === undefined || + (isBoundedString(args.executionHostId, MAX_ID_LENGTH) && + normalizeExecutionHostId(args.executionHostId) !== null)) && isBoundedString(args.tabId, MAX_ID_LENGTH) && (args.leafId === null || isBoundedString(args.leafId, MAX_ID_LENGTH)) ) @@ -73,6 +86,15 @@ export function isDashboardPaneKey(value: unknown): value is string { return isBoundedString(value, MAX_ID_LENGTH) } +export function isDashboardSleepWorkspaceArgs( + value: unknown +): value is DashboardSleepWorkspaceArgs { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + return isBoundedString((value as Record).worktreeId, MAX_ID_LENGTH) +} + export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -83,8 +105,10 @@ export function isDashboardSnapshot(value: unknown): value is DashboardSnapshot Array.isArray(snapshot.cards) && snapshot.cards.length <= MAX_DASHBOARD_CARDS && snapshot.cards.every(isDashboardCard) && + isDashboardWorkspaceList(snapshot.workspaces) && (snapshot.showIdle === undefined || typeof snapshot.showIdle === 'boolean') && isDashboardFilterOptions(snapshot.filterOptions) && + isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) && isDashboardRepoIcons(snapshot.repoIconsByRepoId) ) } @@ -107,55 +131,30 @@ export function admitDashboardSnapshot(value: unknown): DashboardSnapshotAdmissi return null } const snapshot = value as Record + const workspaces = admitDashboardWorkspaces(snapshot.workspaces) if ( !isFiniteNumber(snapshot.generatedAt) || !Array.isArray(snapshot.cards) || snapshot.cards.length > MAX_DASHBOARD_CARDS || + workspaces === null || (snapshot.showIdle !== undefined && typeof snapshot.showIdle !== 'boolean') || !isDashboardFilterOptions(snapshot.filterOptions) || + !isDashboardLaunchOptions(snapshot.launchableAgentsByWorktreeId) || !isDashboardRepoIcons(snapshot.repoIconsByRepoId) ) { return null } const cards = snapshot.cards.filter(isDashboardCard) return { - snapshot: { ...(snapshot as unknown as DashboardSnapshot), cards }, + snapshot: { + ...(snapshot as unknown as DashboardSnapshot), + cards, + ...(workspaces ? { workspaces } : {}) + }, droppedCardCount: snapshot.cards.length - cards.length } } -function isDashboardFilterOptions(value: unknown): boolean { - if (value === undefined) { - return true - } - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - const options = value as Record - return ( - isDashboardFilterOptionList(options.projects) && - isDashboardFilterOptionList(options.workspaceStatuses) - ) -} - -function isDashboardFilterOptionList(value: unknown): boolean { - return ( - Array.isArray(value) && - value.length <= MAX_DASHBOARD_FILTER_OPTIONS && - value.every((entry) => { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - return false - } - const option = entry as Record - return ( - isBoundedString(option.id, MAX_ID_LENGTH) && - isBoundedString(option.label, MAX_LABEL_LENGTH, true) && - isOptionalBoundedString(option.color, MAX_ID_LENGTH) - ) - }) - ) -} - /** Repo icons reach the pop-out's ``, so each one must survive the * same sanitizer the settings picker writes through. */ function isDashboardRepoIcons(value: unknown): boolean { @@ -264,8 +263,18 @@ function isDashboardCard(value: unknown): boolean { isBoundedString(card.worktreeId, MAX_ID_LENGTH) && isBoundedString(card.tabId, MAX_ID_LENGTH) && (card.leafId === null || isBoundedString(card.leafId, MAX_ID_LENGTH)) && + isOptionalBoundedString(card.parentPaneKey, MAX_ID_LENGTH) && + isOptionalBoundedString(card.parentWorktreeId, MAX_ID_LENGTH) && isBoundedString(card.repoName, MAX_LABEL_LENGTH, true) && isBoundedString(card.worktreeName, MAX_LABEL_LENGTH, true) && + (card.hostKind === undefined || + (typeof card.hostKind === 'string' && DASHBOARD_HOST_KINDS.has(card.hostKind))) && + (card.executionHostId === undefined || + (isBoundedString(card.executionHostId, MAX_ID_LENGTH) && + normalizeExecutionHostId(card.executionHostId) !== null)) && + (card.workspaceKind === undefined || + (typeof card.workspaceKind === 'string' && + DASHBOARD_WORKSPACE_KINDS.has(card.workspaceKind))) && isOptionalBoundedString(card.workspaceStatusId, MAX_ID_LENGTH) && isOptionalBoundedString(card.workspaceStatusLabel, MAX_LABEL_LENGTH) && isOptionalBoundedString(card.workspaceStatusColor, MAX_ID_LENGTH) && @@ -275,6 +284,7 @@ function isDashboardCard(value: unknown): boolean { isFiniteNumber(card.startedAt) && (card.finishedAt === null || isFiniteNumber(card.finishedAt)) && isFiniteNumber(card.stateChangedAt) && + (card.statusUpdatedAt === undefined || isFiniteNumber(card.statusUpdatedAt)) && typeof card.unseen === 'boolean' && isOptionalBoundedString(card.askSummary, AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) && isOptionalBoundedString(card.conversationName, MAX_LABEL_LENGTH) && diff --git a/src/main/ipc/dashboard-popout.test.ts b/src/main/ipc/dashboard-popout.test.ts index d516faa4554..ce10b3556fc 100644 --- a/src/main/ipc/dashboard-popout.test.ts +++ b/src/main/ipc/dashboard-popout.test.ts @@ -127,6 +127,15 @@ describe('registerDashboardPopoutHandlers', () => { expect(createPopoutMock).toHaveBeenCalledWith(store, undefined, { getKeybindings: expect.any(Function) }) + + handlers.get('dashboardPopout:open')!({ sender: mainSender } as never, 'map') + expect(createPopoutMock).toHaveBeenLastCalledWith(store, 'map', { + getKeybindings: expect.any(Function) + }) + + createPopoutMock.mockClear() + handlers.get('dashboardPopout:open')!({ sender: mainSender } as never, 'invalid') + expect(createPopoutMock).not.toHaveBeenCalled() }) it('auto-closes the popout when the feature is disabled', () => { @@ -240,6 +249,31 @@ describe('registerDashboardPopoutHandlers', () => { expect(sendToTrustedMock).toHaveBeenCalledWith('ui:ackDashboardAgent', 'tab1:leaf1') }) + it('relays only valid agent launches from the popout', () => { + const args = { worktreeId: 'worktree-1', agent: 'codex' } + handlers.get('dashboardPopout:spawnAgent')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:spawnAgent')!({ sender: popoutSender } as never, { + ...args, + agent: 'unknown' + }) + expect(sendToTrustedMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:spawnAgent')!({ sender: popoutSender } as never, args) + expect(sendToTrustedMock).toHaveBeenCalledWith('ui:spawnDashboardAgent', args) + }) + + it('relays only valid sleep requests from the popout', () => { + const args = { worktreeId: 'worktree-1' } + handlers.get('dashboardPopout:sleepWorkspace')!({ sender: untrustedSender } as never, args) + handlers.get('dashboardPopout:sleepWorkspace')!({ sender: popoutSender } as never, { + worktreeId: '' + }) + expect(sendToTrustedMock).not.toHaveBeenCalled() + + handlers.get('dashboardPopout:sleepWorkspace')!({ sender: popoutSender } as never, args) + expect(sendToTrustedMock).toHaveBeenCalledWith('ui:sleepDashboardWorkspace', args) + }) + it('reveals an agent in only the trusted main window', () => { const main = makeWindow(mainSender) getTrustedWindowMock.mockReturnValue(main) diff --git a/src/main/ipc/dashboard-popout.ts b/src/main/ipc/dashboard-popout.ts index c7a26421878..5b4781ec761 100644 --- a/src/main/ipc/dashboard-popout.ts +++ b/src/main/ipc/dashboard-popout.ts @@ -14,7 +14,9 @@ import { getTrustedUIRendererWindow, isTrustedUIRenderer, sendToTrustedUIRendere import { admitDashboardSnapshot, isDashboardPaneKey, - isDashboardRevealAgentArgs + isDashboardRevealAgentArgs, + isDashboardSleepWorkspaceArgs, + isDashboardSpawnAgentArgs } from './dashboard-payload-validation' // The most recent snapshot the main renderer published, replayed to the popout @@ -36,6 +38,8 @@ export function registerDashboardPopoutHandlers( ipcMain.removeHandler('dashboard:getPopoutOpen') ipcMain.removeHandler('dashboardPopout:revealAgent') ipcMain.removeHandler('dashboardPopout:ackAgent') + ipcMain.removeHandler('dashboardPopout:spawnAgent') + ipcMain.removeHandler('dashboardPopout:sleepWorkspace') onDashboardPopoutOpenChanged((open) => { if (!open) { @@ -52,11 +56,14 @@ export function registerDashboardPopoutHandlers( } }) - ipcMain.handle('dashboardPopout:open', (event): void => { + ipcMain.handle('dashboardPopout:open', (event, view: unknown): void => { if (!isTrustedUIRenderer(event.sender) || !isDashboardEnabled(store)) { return } - createOrFocusDashboardPopout(store, undefined, { + if (view !== undefined && view !== 'board' && view !== 'map') { + return + } + createOrFocusDashboardPopout(store, view, { getKeybindings: () => keybindings?.getOverrides() }) }) @@ -141,4 +148,26 @@ export function registerDashboardPopoutHandlers( // Best-effort; the per-window focus above may still bring it forward. } }) + + ipcMain.handle('dashboardPopout:spawnAgent', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardSpawnAgentArgs(args) + ) { + return + } + sendToTrustedUIRenderer('ui:spawnDashboardAgent', args) + }) + + ipcMain.handle('dashboardPopout:sleepWorkspace', (event, args: unknown): void => { + if ( + !isDashboardPopoutRenderer(event.sender) || + !isDashboardEnabled(store) || + !isDashboardSleepWorkspaceArgs(args) + ) { + return + } + sendToTrustedUIRenderer('ui:sleepDashboardWorkspace', args) + }) } diff --git a/src/main/ipc/dashboard-workspace-payload-validation.ts b/src/main/ipc/dashboard-workspace-payload-validation.ts new file mode 100644 index 00000000000..88e16d9af45 --- /dev/null +++ b/src/main/ipc/dashboard-workspace-payload-validation.ts @@ -0,0 +1,80 @@ +import { + DASHBOARD_MAX_LABEL_LENGTH, + DASHBOARD_MAX_MAP_WORKSPACES, + type DashboardWorkspace +} from '../../shared/dashboard-snapshot' +import { normalizeExecutionHostId } from '../../shared/execution-host' + +const MAX_ID_LENGTH = 4_096 +const HOST_KINDS = new Set(['local', 'ssh', 'wsl', 'remote']) +const WORKSPACE_KINDS = new Set(['worktree', 'folder']) +const REVIEW_STATES = new Set(['open', 'closed', 'merged', 'draft']) + +function isString(value: unknown, maxLength: number, allowEmpty = false): value is string { + return typeof value === 'string' && value.length <= maxLength && (allowEmpty || value.length > 0) +} + +function isOptionalString(value: unknown, maxLength: number): boolean { + return value === undefined || isString(value, maxLength, true) +} + +function isReview(value: unknown): boolean { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const review = value as Record + return ( + typeof review.number === 'number' && + Number.isFinite(review.number) && + review.number > 0 && + typeof review.state === 'string' && + REVIEW_STATES.has(review.state) + ) +} + +export function isDashboardWorkspace(value: unknown): value is DashboardWorkspace { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const workspace = value as Record + return ( + isString(workspace.repoId, MAX_ID_LENGTH) && + isString(workspace.worktreeId, MAX_ID_LENGTH) && + isString(workspace.repoName, DASHBOARD_MAX_LABEL_LENGTH, true) && + isString(workspace.worktreeName, DASHBOARD_MAX_LABEL_LENGTH, true) && + isOptionalString(workspace.parentWorktreeId, MAX_ID_LENGTH) && + typeof workspace.hostKind === 'string' && + HOST_KINDS.has(workspace.hostKind) && + isString(workspace.executionHostId, MAX_ID_LENGTH) && + normalizeExecutionHostId(workspace.executionHostId) !== null && + typeof workspace.workspaceKind === 'string' && + WORKSPACE_KINDS.has(workspace.workspaceKind) && + isOptionalString(workspace.workspaceStatusId, MAX_ID_LENGTH) && + isOptionalString(workspace.workspaceStatusLabel, DASHBOARD_MAX_LABEL_LENGTH) && + isOptionalString(workspace.workspaceStatusColor, MAX_ID_LENGTH) && + (workspace.hasReview === undefined || typeof workspace.hasReview === 'boolean') && + isReview(workspace.review) + ) +} + +export function isDashboardWorkspaceList(value: unknown): boolean { + return ( + value === undefined || + (Array.isArray(value) && + value.length <= DASHBOARD_MAX_MAP_WORKSPACES && + value.every(isDashboardWorkspace)) + ) +} + +export function admitDashboardWorkspaces(value: unknown): DashboardWorkspace[] | undefined | null { + if (value === undefined) { + return undefined + } + if (!Array.isArray(value) || value.length > DASHBOARD_MAX_MAP_WORKSPACES) { + return null + } + return value.filter(isDashboardWorkspace) +} diff --git a/src/main/window/dashboard-popout-window.test.ts b/src/main/window/dashboard-popout-window.test.ts index 0976a922e16..7ffa56d7b53 100644 --- a/src/main/window/dashboard-popout-window.test.ts +++ b/src/main/window/dashboard-popout-window.test.ts @@ -231,6 +231,12 @@ describe('createOrFocusDashboardPopout', () => { expect(options).toEqual({ search: 'view=kanban' }) }) + it('opens on the current dashboard view by default', () => { + createOrFocusDashboardPopout(makeStore() as never) + + expect(instances[0].loadFile.mock.calls[0][1]).toEqual({ search: 'view=board' }) + }) + it('loads the dev server URL with the requested view when in dev', () => { isMock.dev = true vi.stubEnv('ELECTRON_RENDERER_URL', RENDERER_URL) @@ -249,6 +255,16 @@ describe('createOrFocusDashboardPopout', () => { expect(instances[0].focus).toHaveBeenCalledTimes(1) }) + it('switches an existing popout to an explicitly requested view', () => { + const store = makeStore() + createOrFocusDashboardPopout(store as never) + const win = instances[0] + + createOrFocusDashboardPopout(store as never, 'map') + + expect(win.webContents.send).toHaveBeenCalledWith('dashboard:viewRequested', 'map') + }) + it('trusts only the live popout webContents', () => { const win = createOrFocusDashboardPopout(makeStore() as never) as unknown as FakeWindow expect(isDashboardPopoutRenderer(win.webContents as never)).toBe(true) diff --git a/src/main/window/dashboard-popout-window.ts b/src/main/window/dashboard-popout-window.ts index 32fb00008ff..d23742f375a 100644 --- a/src/main/window/dashboard-popout-window.ts +++ b/src/main/window/dashboard-popout-window.ts @@ -18,7 +18,7 @@ const MIN_WIDTH = 480 const MIN_HEIGHT = 360 const DEFAULT_WIDTH = 960 const DEFAULT_HEIGHT = 720 -const DEFAULT_VIEW = 'kanban' +const DEFAULT_VIEW = 'board' const DASHBOARD_POPOUT_PARTITION = 'orca-dashboard-popout' // Why: singleton — the dashboard is a companion surface, so a second "Pop Out" @@ -138,7 +138,7 @@ function resolveRestoredBounds(store: Store | null): { */ export function createOrFocusDashboardPopout( store: Store | null, - view: string = DEFAULT_VIEW, + view?: string, options: { getKeybindings?: () => KeybindingOverrides | undefined } = {} ): BrowserWindow { if (dashboardPopoutWindow && !dashboardPopoutWindow.isDestroyed()) { @@ -146,9 +146,14 @@ export function createOrFocusDashboardPopout( dashboardPopoutWindow.restore() } dashboardPopoutWindow.focus() + if (view) { + dashboardPopoutWindow.webContents.send('dashboard:viewRequested', view) + } return dashboardPopoutWindow } + const initialView = view ?? DEFAULT_VIEW + const savedBounds = resolveRestoredBounds(store) const window = new BrowserWindow({ @@ -283,7 +288,7 @@ export function createOrFocusDashboardPopout( broadcastPopoutOpenChanged(false) }) - loadDashboardPopout(window, view) + loadDashboardPopout(window, initialView) return window } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 53bdb0e7349..69a6f104b22 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -10,7 +10,12 @@ import type { } from '../shared/hosted-review' import type { NativeFileDropPayload } from '../shared/native-file-drop' import type { BrowserFindSource } from '../shared/browser-find-source' -import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + DashboardRevealAgentArgs, + DashboardSleepWorkspaceArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload @@ -2497,17 +2502,22 @@ export type PreloadApi = { ) => Promise } dashboard: { - openPopout: () => Promise + openPopout: (view?: 'board' | 'map') => Promise publishSnapshot: (snapshot: DashboardSnapshot) => Promise getPopoutOpen: () => Promise onPopoutOpenChanged: (callback: (open: boolean) => void) => () => void onSnapshotRequested: (callback: () => void) => () => void onRevealAgent: (callback: (args: DashboardRevealAgentArgs) => void) => () => void onAckAgent: (callback: (paneKey: string) => void) => () => void + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void) => () => void + onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void) => () => void requestSnapshot: () => Promise onSnapshot: (callback: (snapshot: DashboardSnapshot) => void) => () => void + onViewRequested: (callback: (view: 'board' | 'map') => void) => () => void revealAgent: (args: DashboardRevealAgentArgs) => Promise ackAgent: (paneKey: string) => Promise + spawnAgent: (args: DashboardSpawnAgentArgs) => Promise + sleepWorkspace: (args: DashboardSleepWorkspaceArgs) => Promise } terminalPreview: { connect: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index cf602b3f1cc..74ccf8b3876 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,12 @@ import { electronAPI } from '@electron-toolkit/preload' import { preloadE2EConfig } from './e2e-config' import { glApi } from './gitlab' import type { AppIdentity } from '../shared/app-identity' -import type { DashboardSnapshot, DashboardRevealAgentArgs } from '../shared/dashboard-snapshot' +import type { + DashboardRevealAgentArgs, + DashboardSleepWorkspaceArgs, + DashboardSnapshot, + DashboardSpawnAgentArgs +} from '../shared/dashboard-snapshot' import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload @@ -2280,7 +2285,8 @@ const api = { dashboard: { // Open the pop-out dashboard window, or focus it if already open. - openPopout: (): Promise => ipcRenderer.invoke('dashboardPopout:open'), + openPopout: (view?: 'board' | 'map'): Promise => + ipcRenderer.invoke('dashboardPopout:open', view), // ── Producer side (main window) ────────────────────────────────────── publishSnapshot: (snapshot: DashboardSnapshot): Promise => @@ -2308,6 +2314,20 @@ const api = { ipcRenderer.on('ui:ackDashboardAgent', listener) return () => ipcRenderer.removeListener('ui:ackDashboardAgent', listener) }, + onSpawnAgent: (callback: (args: DashboardSpawnAgentArgs) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, args: DashboardSpawnAgentArgs): void => + callback(args) + ipcRenderer.on('ui:spawnDashboardAgent', listener) + return () => ipcRenderer.removeListener('ui:spawnDashboardAgent', listener) + }, + onSleepWorkspace: (callback: (args: DashboardSleepWorkspaceArgs) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + args: DashboardSleepWorkspaceArgs + ): void => callback(args) + ipcRenderer.on('ui:sleepDashboardWorkspace', listener) + return () => ipcRenderer.removeListener('ui:sleepDashboardWorkspace', listener) + }, // ── Consumer side (pop-out window) ─────────────────────────────────── requestSnapshot: (): Promise => ipcRenderer.invoke('dashboard:requestSnapshot'), @@ -2317,10 +2337,20 @@ const api = { ipcRenderer.on('dashboard:snapshot', listener) return () => ipcRenderer.removeListener('dashboard:snapshot', listener) }, + onViewRequested: (callback: (view: 'board' | 'map') => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, view: 'board' | 'map'): void => + callback(view) + ipcRenderer.on('dashboard:viewRequested', listener) + return () => ipcRenderer.removeListener('dashboard:viewRequested', listener) + }, revealAgent: (args: DashboardRevealAgentArgs): Promise => ipcRenderer.invoke('dashboardPopout:revealAgent', args), ackAgent: (paneKey: string): Promise => - ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }) + ipcRenderer.invoke('dashboardPopout:ackAgent', { paneKey }), + spawnAgent: (args: DashboardSpawnAgentArgs): Promise => + ipcRenderer.invoke('dashboardPopout:spawnAgent', args), + sleepWorkspace: (args: DashboardSleepWorkspaceArgs): Promise => + ipcRenderer.invoke('dashboardPopout:sleepWorkspace', args) }, terminalPreview: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9874acecd66..8319d686597 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -92,7 +92,6 @@ import RecentTabSwitcher from './components/tab-bar/RecentTabSwitcher' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' -import { useDashboardPopoutBridge } from './components/dashboard/useDashboardPopoutBridge' import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { resolvePrimarySelectionMiddleClickPaste, @@ -389,6 +388,7 @@ const FloatingTerminalPanel = lazy(() => ) // Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on. const PetOverlay = lazy(() => import('./components/pet/PetOverlay')) +const DashboardPopoutBridge = lazy(() => import('./components/dashboard/DashboardPopoutBridge')) // Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch. const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow')) @@ -757,8 +757,6 @@ function App(): React.JSX.Element { useEditorExternalWatch() useGlobalFileDrop() useAutoAckViewedAgent() - useDashboardPopoutBridge(settings?.experimentalAgentDashboardPopout === true) - useEffect(() => { return onOnboardingReopened(setOnboarding) }, []) @@ -2249,6 +2247,11 @@ function App(): React.JSX.Element { {/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */} + {settings?.experimentalAgentDashboardPopout === true ? ( + + + + ) : null} {/* Why: workspace activation is a hot path; activeWorktreeId in reset keys would remount whole surfaces during wake. */} string + showAgentlessWorkspaces: boolean onProjectToggle: (id: string) => void onStatusToggle: (id: string) => void onReviewToggle: (id: DashboardReviewFilter) => void + onAgentlessWorkspacesToggle: () => void onClear: () => void }): React.JSX.Element { return ( @@ -74,6 +78,15 @@ export function AgentDashboardFilterChips({ onRemove={() => onReviewToggle(id)} /> ))} + {showAgentlessWorkspaces ? ( + + ) : null} - ) : null} + ) : ( + + )}
{query || activeCount > 0 ? ( @@ -215,6 +274,47 @@ export function AgentDashboardToolbar({ + {showAgentlessWorkspaces !== undefined && onShowAgentlessWorkspacesChange ? ( + <> + + {translate('dashboardPopout.map.filters.workspaceVisibility', 'Map content')} + + onShowAgentlessWorkspacesChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + + {translate( + 'dashboardPopout.map.filters.agentlessWorkspaces', + 'Workspaces without agents' + )} + + + + + + ) : null} + {agentStates && agentStateCounts ? ( + <> + + {translate('dashboardPopout.map.filters.showStates', 'Agent states')} + + {AGENT_STATE_ROWS.map(({ state, dotState }) => ( + onAgentStateToggle?.(state)} + onSelect={(event) => event.preventDefault()} + > + + {agentStateLabel(state)} + + + ))} + + + ) : null} {translate('dashboardPopout.filters.project', 'Project')} @@ -285,9 +385,11 @@ export function AgentDashboardToolbar({ projects={projects} statuses={statuses} reviewLabel={reviewLabel} + showAgentlessWorkspaces={showAgentlessWorkspaces === true} onProjectToggle={toggleProject} onStatusToggle={toggleStatus} onReviewToggle={toggleReview} + onAgentlessWorkspacesToggle={() => onShowAgentlessWorkspacesChange?.(false)} onClear={clearFilters} /> ) : null} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx index f9390570e30..58bb890d139 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.test.tsx @@ -3,15 +3,20 @@ import '@testing-library/jest-dom/vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import type { DashboardCard, DashboardFilterOptions, - DashboardSnapshot + DashboardSnapshot, + DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { i18n } from '@/i18n/i18n' import { AgentKanbanBoard } from './AgentKanbanBoard' +const MAP_LOAD_TIMEOUT = { timeout: 5_000 } + // Stub the card and dialog so the board test stays free of xterm / Radix // machinery while still exercising the board-owned dialog wiring. vi.mock('./AgentKanbanCard', () => ({ @@ -54,6 +59,17 @@ vi.mock('./AgentTerminalDialog', () => ({ >
+ ), + AgentTerminalPanel: ({ + card, + onOpenChange + }: { + card: DashboardCard | null + onOpenChange: (open: boolean) => void + }) => ( +
+
) })) @@ -79,12 +95,26 @@ function card(overrides: Partial): DashboardCard { } } +function workspace(overrides: Partial = {}): DashboardWorkspace { + return { + repoId: 'r1', + worktreeId: 'w1', + repoName: 'Repo', + worktreeName: 'wt', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + function renderBoard( cards: DashboardCard[], options: { showIdle?: boolean repoIconsByRepoId?: Record filterOptions?: DashboardFilterOptions + workspaces?: DashboardWorkspace[] } = {} ): void { const snapshot: DashboardSnapshot = { generatedAt: 1, cards, ...options } @@ -96,6 +126,11 @@ const ackAgent = vi.fn(async () => {}) describe('AgentKanbanBoard', () => { beforeEach(async () => { await i18n.changeLanguage('en') + localStorage.clear() + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) // The board relays seen-acks through the dashboard preload API. ;(window as unknown as { api: unknown }).api = { dashboard: { ackAgent } } }) @@ -104,6 +139,7 @@ describe('AgentKanbanBoard', () => { vi.useRealTimers() vi.clearAllMocks() vi.restoreAllMocks() + vi.unstubAllGlobals() }) it('renders the three default columns in order', () => { @@ -112,6 +148,160 @@ describe('AgentKanbanBoard', () => { expect(headers.map((h) => h.textContent)).toEqual(['Needs You', 'Working', 'Done']) }) + it('loads the map as a recoverable dynamic chunk', () => { + const source = readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx'), + 'utf8' + ) + + expect(source).toContain("import { lazyWithRetry } from '@/lib/lazy-with-retry'") + expect(source).toMatch(/import\('\.\/AgentDashboardMapView'\)/) + expect(source).not.toMatch(/from\s+['"]\.\/(?:AgentMap|useAgentMap|agent-map-)/) + }) + + it('keeps the dashboard and map available as separate views', async () => { + renderBoard([]) + + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + expect( + await screen.findByText('0 of 0 agents shown', undefined, MAP_LOAD_TIMEOUT) + ).toBeInTheDocument() + expect(screen.queryByText('Live containment map')).not.toBeInTheDocument() + // The map has no rail of its own; its filters live in the shared toolbar. + expect(screen.queryByRole('complementary', { name: 'Map filters' })).not.toBeInTheDocument() + expect(screen.queryByText('Agent states')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })) + expect(screen.getByText('Needs You')).toBeInTheDocument() + }) + + it('filters the map from the shared toolbar filter, not a rail', async () => { + renderBoard([ + card({ paneKey: 'busy', worktreeName: 'busy-wt', worktreeId: 'w-busy' }), + card({ + paneKey: 'finished', + worktreeName: 'done-wt', + worktreeId: 'w-done', + bucket: 'done', + dotState: 'done', + finishedAt: 5, + unseen: true + }) + ]) + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + expect( + await screen.findByText('2 of 2 agents shown', undefined, MAP_LOAD_TIMEOUT) + ).toBeInTheDocument() + + fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) + fireEvent.click(await screen.findByRole('menuitemcheckbox', { name: /Working/ })) + + expect(await screen.findByText('1 of 2 agents shown')).toBeInTheDocument() + // A muted state counts toward the Filter badge like any other filter. The + // open menu hides the trigger from the a11y tree, so read it by its label. + expect(screen.getByRole('menu')).toHaveAccessibleName('Filter 1') + }) + + it('offers agent states only on the map, where no column separates them', async () => { + renderBoard([card({ paneKey: 'busy' })]) + + fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) + expect(await screen.findByText('Project')).toBeInTheDocument() + expect(screen.queryByText('Agent states')).not.toBeInTheDocument() + + fireEvent.keyDown(document.body, { key: 'Escape' }) + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) + + expect(await screen.findByText('Agent states')).toBeInTheDocument() + }) + + it('toggles workspaces without agents from the shared map filter', async () => { + renderBoard([card({ paneKey: 'busy' })], { + workspaces: [workspace(), workspace({ worktreeId: 'empty', worktreeName: 'Empty child' })] + }) + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + + expect( + screen.queryByRole('button', { name: 'Open Empty child worktree details' }) + ).not.toBeInTheDocument() + fireEvent.pointerDown(screen.getByRole('button', { name: /^Filter/ })) + const workspaceToggle = await screen.findByRole('menuitemcheckbox', { + name: /Workspaces without agents/ + }) + fireEvent.click(workspaceToggle) + + expect(workspaceToggle).toHaveAttribute('aria-checked', 'true') + fireEvent.keyDown(document.body, { key: 'Escape' }) + + expect( + await screen.findByRole( + 'button', + { name: 'Open Empty child worktree details' }, + MAP_LOAD_TIMEOUT + ) + ).toBeInTheDocument() + expect(screen.getByText('Workspaces without agents')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Clear' })) + expect( + screen.queryByRole('button', { name: 'Open Empty child worktree details' }) + ).not.toBeInTheDocument() + }) + + it('keeps the selected map visible beside its terminal panel', async () => { + const agent = card({ paneKey: 'map-agent', conversationName: 'Map agent' }) + render() + + fireEvent.click(await screen.findByRole('button', { name: /Map agent/ }, MAP_LOAD_TIMEOUT)) + + expect(screen.getByLabelText('Nested project, workspace, and agent map')).toBeInTheDocument() + const terminalPanel = screen.getByTestId('terminal-panel') + expect(terminalPanel).toHaveAttribute('data-pty-id', 'p1') + expect(terminalPanel.parentElement).toHaveClass('flex-row-reverse') + expect( + screen.getByLabelText('Nested project, workspace, and agent map').closest('section') + ).toHaveClass('w-1/2', 'flex-none') + expect(screen.getByRole('button', { name: /Map agent/ })).toHaveClass('is-selected') + expect(screen.getByRole('button', { name: /Map agent/ })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + expect(screen.getByText('200%')).toBeInTheDocument() + expect(screen.queryByText('Map filters')).not.toBeInTheDocument() + expect(screen.queryByTestId('terminal-dialog')).not.toBeInTheDocument() + }) + + it('closes the adjacent terminal instead of turning it into a board dialog', async () => { + const agent = card({ paneKey: 'map-agent', conversationName: 'Map agent' }) + render() + + fireEvent.click(await screen.findByRole('button', { name: /Map agent/ }, MAP_LOAD_TIMEOUT)) + expect(screen.getByTestId('terminal-panel')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Agent Map' })) + expect(screen.getByTestId('terminal-panel')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })) + + expect(screen.queryByTestId('terminal-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('terminal-dialog')).toHaveAttribute('data-open', 'false') + }) + + it('focuses search with Ctrl+K without taking focus from response fields', () => { + renderBoard([]) + const search = screen.getByLabelText('Search agents') + + fireEvent.keyDown(document.body, { key: 'k', ctrlKey: true }) + expect(search).toHaveFocus() + + const response = document.createElement('textarea') + document.body.append(response) + response.focus() + fireEvent.keyDown(response, { key: 'k', ctrlKey: true }) + expect(response).toHaveFocus() + response.remove() + }) + it('places cards in their bucket column and counts them', () => { renderBoard([ card({ bucket: 'attention', worktreeName: 'a1' }), @@ -252,7 +442,9 @@ describe('AgentKanbanBoard', () => { it('keeps the terminal dialog open across bucket moves and card removal', () => { const agent = card({ paneKey: 'pk-1', bucket: 'done', worktreeName: 'wt1' }) - const { rerender } = render() + const { rerender } = render( + + ) expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('false') fireEvent.click(screen.getByTestId('card')) @@ -284,9 +476,16 @@ describe('AgentKanbanBoard', () => { // The ack round-trips through the main window; the next snapshot mutes it. rerender( - + ) expect(screen.getByTestId('card').dataset.unseen).toBe('false') + expect(screen.getByTestId('card').dataset.bucket).toBe('idle') expect(ackAgent).not.toHaveBeenCalled() // A state change while the dialog is open re-acks (watching counts as @@ -295,10 +494,55 @@ describe('AgentKanbanBoard', () => { ) expect(ackAgent).toHaveBeenCalledWith('pk-ack') }) + + it('keeps an acknowledged result visible as Idle in the map without review state', async () => { + const fresh = card({ + paneKey: 'fresh-result', + bucket: 'done', + dotState: 'done', + conversationName: 'Fresh result', + finishedAt: 900, + unseen: true + }) + const view = render( + + ) + + expect( + await screen.findByRole('button', { name: /Fresh result/ }, MAP_LOAD_TIMEOUT) + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Fresh result/ })) + expect(ackAgent).toHaveBeenCalledWith('fresh-result') + + view.rerender( + + ) + expect(screen.getByRole('button', { name: /Fresh result/ })).toHaveClass('fleet-status-idle') + expect(screen.getByTestId('terminal-panel')).toBeInTheDocument() + + view.unmount() + render( + + ) + expect(screen.getByRole('button', { name: /Fresh result/ })).toHaveClass('fleet-status-idle') + }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx index ded2be0c221..376f38a1755 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanBoard.tsx @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { XIcon } from 'lucide-react' +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Columns3, Orbit, XIcon } from 'lucide-react' import { DASHBOARD_BUCKET_ORDER, type DashboardBucket, type DashboardCard, - type DashboardSnapshot + type DashboardSleepWorkspaceArgs, + type DashboardSnapshot, + type DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { cn } from '@/lib/utils' @@ -20,6 +22,16 @@ import { } from './agent-board-filtering' import './agent-board-transitions.css' import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { lazyWithRetry } from '@/lib/lazy-with-retry' + +export type AgentDashboardView = 'map' | 'board' + +const AgentDashboardMapView = lazyWithRetry( + () => + import('./AgentDashboardMapView').then((module) => ({ default: module.AgentDashboardMapView })), + { reloadKey: 'agent-dashboard-map-view' } +) /** Ack an agent in the pop-out window: relayed over IPC to the main renderer. * ?. shields dialog-opening from dev-HMR preload skew (renderer updates hot, @@ -35,6 +47,18 @@ function revealAgentViaPopoutRelay(args: AgentRevealArgs): void { void window.api.dashboard.revealAgent?.(args) } +/** Start an agent from the pop-out window: the main renderer owns the store and + * the tab path, so the launch is relayed. Same `?.` HMR-skew guard. */ +function spawnAgentViaPopoutRelay(args: DashboardSpawnAgentArgs): void { + void window.api.dashboard.spawnAgent?.(args) +} + +/** Sleep a workspace from the pop-out window: the main renderer runs the + * teardown, which has to happen where the terminal panes live. */ +function sleepWorkspaceViaPopoutRelay(args: DashboardSleepWorkspaceArgs): void { + void window.api.dashboard.sleepWorkspace?.(args) +} + function bucketLabel(bucket: DashboardBucket): string { switch (bucket) { case 'attention': @@ -114,6 +138,7 @@ function KanbanColumn({ type AgentKanbanBoardProps = { snapshot: DashboardSnapshot + initialView?: AgentDashboardView /** Sizing for the outermost container. The pop-out fills the window * (h-screen w-screen); the in-window drawer fills its host (h-full w-full). */ containerClassName?: string @@ -123,12 +148,24 @@ type AgentKanbanBoardProps = { /** Focuses the agent's pane. Defaults to the pop-out IPC relay; the in-window * host activates the worktree/pane locally and closes the overlay. */ onRevealAgent?: (args: AgentRevealArgs) => void + /** Starts a new agent in a workspace. Defaults to the pop-out IPC relay; the + * in-window host launches through its own store. */ + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + /** Puts a workspace to sleep. Defaults to the pop-out IPC relay; the in-window + * host already offers the full sidebar menu, so it opts out. */ + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void /** When provided, renders a close control in the header (in-window mode). The * pop-out relies on its native window controls, so it omits this. */ onClose?: () => void /** Header controls rendered before the close button. The in-window host * passes its settings menu; the pop-out renderer has no store to drive it. */ headerActions?: React.ReactNode + /** Opens the map outside this renderer. The in-window drawer uses this to + * hand map work to the dedicated pop-out window. */ + onOpenMap?: () => void + /** The shared sidebar workspace menu is available only in the main renderer. */ + workspaceContextMenusEnabled?: boolean + onWorkspaceContextMenuOpenChange?: (open: boolean) => void } /** The agent board: status columns fed by a snapshot. Shared by the pop-out @@ -136,12 +173,19 @@ type AgentKanbanBoardProps = { * how ack/reveal are routed. */ export function AgentKanbanBoard({ snapshot, + initialView = 'board', containerClassName = 'h-screen w-screen', onAckAgent = ackAgentViaPopoutRelay, onRevealAgent = revealAgentViaPopoutRelay, + onSpawnAgent = spawnAgentViaPopoutRelay, + onSleepWorkspace = sleepWorkspaceViaPopoutRelay, onClose, - headerActions + headerActions, + onOpenMap, + workspaceContextMenusEnabled = false, + onWorkspaceContextMenuOpenChange }: AgentKanbanBoardProps): React.JSX.Element { + const [view, setView] = useState(initialView) const visibleBuckets = useMemo( () => DASHBOARD_BUCKET_ORDER.filter((bucket) => bucket !== 'idle' || snapshot.showIdle === true), @@ -151,11 +195,13 @@ export function AgentKanbanBoard({ () => snapshot.cards.filter((card) => visibleBuckets.includes(card.bucket)), [snapshot.cards, visibleBuckets] ) + const availableCards = view === 'map' ? snapshot.cards : visibleCards const [query, setQuery] = useState('') + const searchInputRef = useRef(null) const [filters, setFilters] = useState(EMPTY_DASHBOARD_FILTERS) const filteredCards = useMemo( - () => filterDashboardCards(visibleCards, query, filters), - [filters, query, visibleCards] + () => filterDashboardCards(availableCards, query, filters), + [availableCards, filters, query] ) const grouped = useMemo(() => groupByBucket(filteredCards), [filteredCards]) const hasRelativeTimestamps = useMemo( @@ -163,7 +209,6 @@ export function AgentKanbanBoard({ [snapshot.cards] ) const [now, setNow] = useState(() => Date.now()) - useEffect(() => { if (!hasRelativeTimestamps) { return @@ -174,6 +219,27 @@ export function AgentKanbanBoard({ }) }, [hasRelativeTimestamps]) + useEffect(() => { + const handleSearchShortcut = (event: KeyboardEvent): void => { + const usesPlatformModifier = navigator.userAgent.includes('Mac') + ? event.metaKey + : event.ctrlKey + if (!usesPlatformModifier || event.key.toLowerCase() !== 'k') { + return + } + if ( + event.target instanceof Element && + event.target.closest('input, textarea, [contenteditable="true"], .xterm') + ) { + return + } + event.preventDefault() + searchInputRef.current?.focus() + } + document.addEventListener('keydown', handleSearchShortcut) + return () => document.removeEventListener('keydown', handleSearchShortcut) + }, []) + // The open terminal dialog survives bucket moves: only the paneKey is // remembered, and the card data is re-resolved from each fresh snapshot. // The opened card is kept as a fallback so the dialog also survives the @@ -197,6 +263,20 @@ export function AgentKanbanBoard({ setOpenedCard(null) } }, []) + const handleViewChange = useCallback( + (nextView: AgentDashboardView) => { + if (nextView === 'map' && onOpenMap) { + onOpenMap() + return + } + if (nextView === view) { + return + } + setOpenedCard(null) + setView(nextView) + }, + [onOpenMap, view] + ) // Seen-state is the app-wide ack map (same signal as the sidebar's bold/mute // rows): opening a dialog acks the agent, and the next snapshot comes back @@ -214,23 +294,53 @@ export function AgentKanbanBoard({ if (dialogCard?.unseen) { onAckAgent(dialogCard.paneKey) } - }, [dialogCard?.unseen, dialogCard?.paneKey, onAckAgent]) + }, [dialogCard?.paneKey, dialogCard?.unseen, onAckAgent]) return ( // Why: the pop-out is its own React root with no app-level provider, and the // card's repo tooltip needs one in both hosts. Nesting inside the main // window's provider is harmless. -
+

{translate('dashboardPopout.title', 'Agents')}

{translate('dashboardPopout.total', '{{count}} total', { - count: visibleCards.length + count: availableCards.length })} +
+ + +
{headerActions || onClose ? (
{headerActions} @@ -247,35 +357,63 @@ export function AgentKanbanBoard({
) : null}
- -
- {/* Auto margins center the capped board and collapse during horizontal overflow. */} -
- {visibleBuckets.map((bucket) => ( - - ))} -
-
- + {view !== 'board' ? ( + + + + ) : ( + <> + +
+ {/* Auto margins center the capped board and collapse during horizontal overflow. */} +
+ {visibleBuckets.map((bucket) => ( + + ))} +
+
+ + )} + {view === 'board' ? ( + + ) : null}
) diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx index 24394cae866..5036478d94c 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.test.tsx @@ -159,7 +159,7 @@ describe('AgentKanbanCard', () => { expect(screen.queryByRole('img', { name: 'In review' })).not.toBeInTheDocument() }) - it('tints attention amber and done green, leaving every other state neutral', () => { + it('tints unseen Done green and keeps acknowledged Done neutral as Idle', () => { const { container: attention } = renderCard({ card: card({ bucket: 'attention', dotState: 'waiting' }), now: 2_000 @@ -168,14 +168,14 @@ describe('AgentKanbanCard', () => { cleanup() const { container: done } = renderCard({ - card: card({ bucket: 'idle', dotState: 'done' }), + card: card({ bucket: 'done', dotState: 'done', unseen: true }), now: 2_000 }) expect(done.firstElementChild?.className).toContain('border-emerald-500/40') cleanup() const { container: idle } = renderCard({ - card: card({ bucket: 'idle', dotState: 'idle' }), + card: card({ bucket: 'idle', dotState: 'done', unseen: false }), now: 2_000 }) const idleClassName = idle.firstElementChild?.className ?? '' diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index 556d5f563c1..4e6cffa14af 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -14,7 +14,10 @@ import { AgentStateDot } from '@/components/AgentStateDot' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { + dashboardCardDisplayState, + type DashboardCard +} from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' import { translate } from '@/i18n/i18n' @@ -200,7 +203,8 @@ export const AgentKanbanCard = memo( // "answer me", green for "finished, look at it". Everything else stays // neutral so the tint keeps meaning something. const needsYou = card.bucket === 'attention' - const isDone = card.dotState === 'done' + const displayState = dashboardCardDisplayState(card) + const isDone = displayState === 'done' // Why: the session's own name heads the card. Without one the worktree is // the best heading left — and then the footer drops it rather than say it // twice. @@ -240,7 +244,7 @@ export const AgentKanbanCard = memo( > {heading} - {card.askSummary ? null : } + {card.askSummary ? null : }
{card.lastUserMessage || card.lastAgentMessage ? ( diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx new file mode 100644 index 00000000000..483e9d1e9ed --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -0,0 +1,752 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + DashboardCard, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import { AgentMap } from './AgentMap' +import { agentMapAttentionMarkerScale } from './AgentMapWorktreeRingNode' +import type { AgentMapState } from './agent-map-filter' +import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout' + +const NOW = 2_000_000_000 + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + conversationName: 'Agent alpha', + startedAt: NOW - 10 * 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + hostKind: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +function renderMap( + cards: DashboardCard[], + { + onOpenTerminal = vi.fn(), + selectedPaneKey = null, + compact = false, + workspaceContextMenusEnabled = false, + enabledStates, + launchableAgentsByWorktreeId, + onSpawnAgent, + onSleepWorkspace + }: { + onOpenTerminal?: (card: DashboardCard) => void + selectedPaneKey?: string | null + compact?: boolean + workspaceContextMenusEnabled?: boolean + enabledStates?: ReadonlySet + launchableAgentsByWorktreeId?: Record + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void + } = {} +): ReturnType { + return render( + + ) +} + +describe('AgentMap', () => { + const originalUserAgent = navigator.userAgent + let boundsSpy: ReturnType + + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + boundsSpy = vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function getBounds(this: Element) { + if (this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement) { + return { + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 300, + width: 400, + height: 300, + toJSON: () => ({}) + } + } + return { + x: 0, + y: 0, + left: 0, + top: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON: () => ({}) + } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() + boundsSpy.mockRestore() + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: originalUserAgent + }) + }) + + it('renders the amber marker only for unread agents', () => { + const finished = card({ + paneKey: 'done', + conversationName: 'Finished agent', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: true + }) + renderMap([card(), finished]) + + const workingNode = screen.getByRole('button', { name: /Agent alpha/ }) + const doneNode = screen.getByRole('button', { name: /Finished agent/ }) + expect(workingNode).toHaveClass('fleet-status-working') + expect(doneNode).toHaveClass('fleet-status-done') + expect(workingNode.querySelector('.agent-map-agent-icon svg')).toBeInTheDocument() + expect(workingNode.querySelector('[data-agent-unread-marker]')).not.toBeInTheDocument() + expect(doneNode.querySelector('.agent-map-agent-icon svg')).toBeInTheDocument() + const unreadMarker = doneNode.querySelector('[data-agent-unread-marker]') + expect(unreadMarker).toHaveClass('agent-map-agent-unread-mark') + // On the ring circumference at the top-left diagonal, where the halo breaks the ring. + const onRing = String(-AGENT_MAP_AGENT_RADIUS * Math.SQRT1_2) + expect(unreadMarker).toHaveAttribute('cx', onRing) + expect(unreadMarker).toHaveAttribute('cy', onRing) + expect(Number(unreadMarker?.getAttribute('r'))).toBeGreaterThanOrEqual( + AGENT_MAP_AGENT_RADIUS * 0.225 + ) + expect(unreadMarker).toHaveAttribute('vector-effect', 'none') + expect(doneNode).toHaveAccessibleName(/unread/) + }) + + it('lets attention override working on the worktree glow', () => { + const done = card({ + paneKey: 'done', + conversationName: 'Finished agent', + worktreeId: 'worktree-done', + worktreeName: 'Finished worktree', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000 + }) + const waiting = card({ + paneKey: 'waiting', + conversationName: 'Question agent', + bucket: 'attention', + dotState: 'waiting' + }) + const { container } = renderMap([card(), waiting, done]) + + const workingNode = screen.getByRole('button', { name: /Agent alpha/ }) + const waitingNode = screen.getByRole('button', { name: /Question agent/ }) + const doneNode = screen.getByRole('button', { name: /Finished agent/ }) + const workingRing = screen.getByRole('button', { + name: /Open Agent map worktree details/ + }) + const doneRing = screen.getByRole('button', { + name: /Open Finished worktree worktree details/ + }) + + expect(workingNode.querySelector('[data-agent-map-agent-working-glow]')).toBeInTheDocument() + expect(waitingNode.querySelector('[data-agent-map-agent-working-glow]')).not.toBeInTheDocument() + expect(doneNode.querySelector('[data-agent-map-agent-working-glow]')).not.toBeInTheDocument() + expect(workingRing).toHaveClass('is-waiting') + expect(workingRing).not.toHaveClass('is-working') + expect(doneRing).not.toHaveClass('is-working') + expect(container.querySelector('[data-agent-map-worktree-status-glow]')).toHaveAttribute( + 'data-worktree-active-status', + 'waiting' + ) + expect(container.querySelectorAll('[data-agent-map-worktree-status-glow]')).toHaveLength(1) + }) + + it('keeps glow markup bounded for a large visible worktree', () => { + const cards = Array.from({ length: 120 }, (_, index) => { + const working = index % 2 === 0 + return card({ + paneKey: `pane-${index}`, + ptyId: `pty-${index}`, + tabId: `tab-${index}`, + leafId: `leaf-${index}`, + conversationName: `Agent ${index}`, + bucket: working ? 'working' : 'done', + dotState: working ? 'working' : 'done', + finishedAt: working ? null : NOW - 60_000 + }) + }) + const { container } = renderMap(cards, { selectedPaneKey: 'pane-0' }) + + expect(container.querySelectorAll('[data-agent-map-agent-working-glow]')).toHaveLength(60) + expect(container.querySelectorAll('[data-agent-map-worktree-status-glow]')).toHaveLength(1) + expect(container.querySelectorAll('filter')).toHaveLength(0) + }) + + it('enlarges unread markers when the full fleet is zoomed out', () => { + const fleet = Array.from({ length: 72 }, (_, index) => + card({ + paneKey: `pane-${index}`, + ptyId: `pty-${index}`, + tabId: `tab-${index}`, + leafId: `leaf-${index}`, + worktreeId: `worktree-${index}`, + worktreeName: `Worktree ${index}`, + conversationName: `Agent ${index}`, + unseen: index === 0 + }) + ) + const { container } = renderMap(fleet) + const marker = container.querySelector('[data-agent-unread-marker]')! + const agentMark = + marker.parentElement!.querySelector('.agent-map-agent-mark')! + + expect(Number(marker.getAttribute('r'))).toBeGreaterThan( + Number(agentMark.getAttribute('r')) * 0.225 + ) + expect(marker).toHaveAttribute('vector-effect', 'none') + }) + + it('grows attention markers more gently than the inverse zoom while keeping a size floor', () => { + const mapScale = 0.2 + const markerScale = agentMapAttentionMarkerScale(mapScale) + + expect(markerScale).toBeGreaterThan(1) + expect(markerScale).toBeLessThan(1 / mapScale) + expect(AGENT_MAP_AGENT_RADIUS * 0.225 * markerScale * mapScale).toBeGreaterThanOrEqual(2.25) + }) + + it('shows worktree details and opens a running agent', () => { + const onOpenTerminal = vi.fn() + const running = card() + renderMap([running], { onOpenTerminal }) + const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' }) + fireEvent.click(ring) + + expect(ring).toHaveClass('is-open') + expect(screen.queryByRole('button', { name: 'Focus ring' })).not.toBeInTheDocument() + // The popover names the project; the map itself draws it uppercased. + expect(screen.getByText('Orca')).toBeInTheDocument() + expect(screen.getByText('1 agent · 1 active · 0 done')).toBeInTheDocument() + expect(screen.getByText('Agent alpha')).toBeInTheDocument() + fireEvent.click(screen.getAllByRole('button', { name: /Agent alpha/ })[1]) + expect(onOpenTerminal).toHaveBeenCalledWith(running) + expect(ring).not.toHaveClass('is-open') + }) + + it('starts a new agent from the worktree details picker', () => { + const onSpawnAgent = vi.fn() + renderMap([card()], { + onSpawnAgent, + launchableAgentsByWorktreeId: { 'worktree-1': ['claude', 'codex'] } + }) + fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(screen.getByText('Start a new agent')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Codex/ })) + + // The raw worktree id, not the host-qualified map identity. + expect(onSpawnAgent).toHaveBeenCalledWith({ worktreeId: 'worktree-1', agent: 'codex' }) + }) + + it('explains an empty picker rather than offering nothing', () => { + renderMap([card()], { onSpawnAgent: vi.fn() }) + fireEvent.click(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(screen.getByText('No enabled agents detected.')).toBeInTheDocument() + }) + + it('offers sleep and launch on right-click where the store menu is unavailable', async () => { + const onSleepWorkspace = vi.fn() + renderMap([card()], { + onSleepWorkspace, + onSpawnAgent: vi.fn(), + launchableAgentsByWorktreeId: { 'worktree-1': ['claude'] } + }) + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }), { + clientX: 10, + clientY: 10 + }) + + expect(await screen.findByText('Start a new agent')).toBeInTheDocument() + fireEvent.click(screen.getByText('Sleep')) + expect(onSleepWorkspace).toHaveBeenCalledWith({ worktreeId: 'worktree-1' }) + }) + + it('toggles worktree details from the keyboard', () => { + renderMap([card()]) + const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' }) + + fireEvent.keyDown(ring, { key: 'Enter' }) + expect(ring).toHaveClass('is-open') + fireEvent.keyDown(ring, { key: 'Enter' }) + expect(ring).not.toHaveClass('is-open') + }) + + it('labels folder workspaces without presenting them as worktrees', () => { + renderMap([card({ workspaceKind: 'folder', worktreeName: 'Documentation' })]) + + expect( + screen.getByRole('button', { name: 'Open Documentation folder workspace details' }) + ).toBeInTheDocument() + }) + + it('connects spawned workers beneath their visible parent', () => { + const parent = card({ paneKey: 'parent', conversationName: 'Coordinator' }) + const child = card({ + paneKey: 'child', + parentPaneKey: 'parent', + conversationName: 'Worker' + }) + const nested = card({ + paneKey: 'nested', + parentPaneKey: 'child', + conversationName: 'Subagent' + }) + const orphan = card({ + paneKey: 'orphan', + parentPaneKey: 'filtered-parent', + conversationName: 'Orphaned worker' + }) + const { container } = renderMap([parent, child, nested, orphan]) + const workerLink = container.querySelector('[data-child-pane-key="child"]') + const subagentLink = container.querySelector('[data-child-pane-key="nested"]') + + expect(screen.getByRole('button', { name: /Coordinator/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^Worker/ })).toBeInTheDocument() + expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(2) + expect(workerLink).toHaveClass('agent-map-lineage-link') + expect(workerLink).not.toHaveClass('is-subagent') + expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') + expect(workerLink).toHaveAttribute('data-parent-pane-key', 'parent') + expect(subagentLink).toHaveClass('agent-map-lineage-link', 'is-subagent') + expect(subagentLink).toHaveAttribute('data-agent-map-lineage-relation', 'subagent') + expect(subagentLink).toHaveAttribute('data-parent-pane-key', 'child') + }) + + it('connects spawned workers across worktree rings', () => { + const parent = card({ + paneKey: 'parent', + worktreeId: 'parent-worktree', + worktreeName: 'Parent workspace', + conversationName: 'Coordinator' + }) + const child = card({ + paneKey: 'child', + worktreeId: 'child-worktree', + worktreeName: 'Child workspace', + parentPaneKey: 'parent', + conversationName: 'Worker' + }) + const nested = card({ + paneKey: 'nested', + worktreeId: 'nested-worktree', + worktreeName: 'Nested workspace', + parentPaneKey: 'child', + conversationName: 'Subagent' + }) + const { container } = renderMap([parent, child, nested]) + const workerLink = container.querySelector('[data-child-pane-key="child"]') + const subagentLink = container.querySelector('[data-child-pane-key="nested"]') + + expect(workerLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree') + expect(workerLink).not.toHaveClass('is-subagent') + expect(workerLink).toHaveAttribute('data-agent-map-lineage-relation', 'orchestration') + expect(subagentLink).toHaveClass('agent-map-lineage-link', 'is-cross-worktree', 'is-subagent') + expect(subagentLink).toHaveAttribute('data-agent-map-lineage-relation', 'subagent') + }) + + it('keeps lineage styling to one lightweight path per relationship at fleet scale', () => { + const cards = Array.from({ length: 240 }, (_, index) => + card({ + paneKey: `agent-${index}`, + parentPaneKey: index === 0 ? undefined : `agent-${index - 1}`, + conversationName: `Agent ${index}` + }) + ) + const { container } = renderMap(cards, { selectedPaneKey: 'agent-0' }) + + expect(container.querySelectorAll('[data-agent-map-lineage-link]')).toHaveLength(239) + expect( + container.querySelectorAll('[data-agent-map-lineage-relation="orchestration"]') + ).toHaveLength(1) + expect(container.querySelectorAll('[data-agent-map-lineage-relation="subagent"]')).toHaveLength( + 238 + ) + expect(container.querySelectorAll('filter, animate, animateTransform')).toHaveLength(0) + }) + + it('connects visible child worktrees beneath their parent ring', () => { + const { container } = renderMap([ + card({ paneKey: 'parent', worktreeId: 'parent-worktree', worktreeName: 'Parent' }), + card({ + paneKey: 'child', + worktreeId: 'child-worktree', + worktreeName: 'Child', + parentWorktreeId: 'parent-worktree' + }), + card({ + paneKey: 'orphan', + worktreeId: 'orphan-worktree', + worktreeName: 'Orphan', + parentWorktreeId: 'filtered-parent' + }) + ]) + const links = container.querySelectorAll('[data-agent-map-worktree-lineage-link]') + + expect(links).toHaveLength(1) + expect(links[0]).toHaveAttribute('data-parent-worktree-id', 'parent-worktree') + expect(links[0]).toHaveAttribute('data-child-worktree-id', 'child-worktree') + }) + + it('opens the shared dashboard terminal dialog when an agent is clicked', () => { + const onOpenTerminal = vi.fn() + const agent = card() + renderMap([agent], { onOpenTerminal }) + + fireEvent.click(screen.getByRole('button', { name: /Agent alpha/ })) + expect(onOpenTerminal).toHaveBeenCalledWith(agent) + }) + + it('keeps a selected node visible while compacting around an adjacent terminal', () => { + const { container } = renderMap([card()], { selectedPaneKey: 'pane-1', compact: true }) + + const selectedNode = screen.getByRole('button', { name: /Agent alpha/ }) + expect(selectedNode).toHaveClass('is-selected') + expect(screen.getByRole('button', { name: /Agent alpha/ })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + const label = container.querySelector('.agent-map-worktree-label-group')! + expect(selectedNode.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4) + expect(label.querySelector('rect')).not.toBeInTheDocument() + expect(screen.getByText('270%')).toBeInTheDocument() + expect(screen.queryByText('Map filters')).not.toBeInTheDocument() + expect(screen.queryByRole('group', { name: 'Host filter' })).not.toBeInTheDocument() + }) + + it('eases the viewport into the selected agent', () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + ) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const agent = card() + const view = renderMap([agent]) + + view.rerender( + + ) + act(() => frames.shift()?.(0)) + expect(screen.getByText('100%')).toBeInTheDocument() + act(() => frames.shift()?.(120)) + expect(screen.getByText('249%')).toBeInTheDocument() + act(() => frames.shift()?.(240)) + expect(screen.getByText('270%')).toBeInTheDocument() + }) + + it('keeps the selected node centered when topology changes around it', () => { + const selected = card() + const view = renderMap([selected], { selectedPaneKey: selected.paneKey }) + const svg = view.container.querySelector('.agent-map-canvas > svg')! + const selectedNode = (): SVGGElement => + view.container.querySelector('.agent-map-agent-node.is-selected')! + const nodeCenter = (): [number, number] => { + const match = selectedNode() + .getAttribute('transform') + ?.match(/translate\(([^ ]+) ([^)]+)\)/) + return [Number(match?.[1]), Number(match?.[2])] + } + const viewportCenter = (): [number, number] => { + const [x, y, width, height] = svg.getAttribute('viewBox')!.split(' ').map(Number) + return [x + width / 2, y + height / 2] + } + + const originalNodeCenter = nodeCenter() + view.rerender( + + ) + + expect(nodeCenter()).not.toEqual(originalNodeCenter) + expect(viewportCenter()[0]).toBeCloseTo(nodeCenter()[0]) + expect(viewportCenter()[1]).toBeCloseTo(nodeCenter()[1]) + }) + + it('increases map label scale when users zoom out', () => { + const { container } = renderMap([card()]) + const labelGroup = container.querySelector('.agent-map-worktree-label')?.parentElement + const initialScale = Number( + labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] + ) + + const readsBeforeZoom = boundsSpy.mock.calls.length + fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) + + const zoomedScale = Number( + labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] + ) + expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) + expect(zoomedScale).toBeGreaterThan(initialScale) + }) + + it('avoids idle pointer layout reads and batches active viewport updates by frame', () => { + const frames: FrameRequestCallback[] = [] + const requestFrame = vi.fn((callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('requestAnimationFrame', requestFrame) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const { container } = renderMap([card()]) + const svg = container.querySelector('.agent-map-canvas > svg')! + Object.assign(svg, { + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn() + }) + + const readsBeforeHover = boundsSpy.mock.calls.length + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 20, clientY: 20 }) + expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) + + fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) + expect(svg.setPointerCapture).toHaveBeenCalledWith(1) + const readsAfterPointerDown = boundsSpy.mock.calls.length + const initialViewBox = svg.getAttribute('viewBox') + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 30, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) + + expect(boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) + expect(requestFrame).toHaveBeenCalledOnce() + expect(svg).toHaveAttribute('viewBox', initialViewBox) + + act(() => frames.shift()?.(0)) + expect(svg.getAttribute('viewBox')).not.toBe(initialViewBox) + + requestFrame.mockClear() + const readsBeforeWheel = boundsSpy.mock.calls.length + const wheel = (): void => { + const event = new Event('wheel', { bubbles: true, cancelable: true }) + Object.defineProperties(event, { + deltaY: { value: -10 }, + clientX: { value: 100 }, + clientY: { value: 100 } + }) + fireEvent(svg, event) + expect(event.defaultPrevented).toBe(true) + } + wheel() + wheel() + expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) + expect(requestFrame).toHaveBeenCalledOnce() + }) + + it('keeps active labels visible and progressively discloses quiet labels', () => { + const quiet = card({ + paneKey: 'done', + worktreeId: 'quiet-worktree', + worktreeName: 'Quiet result', + conversationName: 'Finished agent', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: false + }) + const { container } = renderMap([card(), quiet]) + const labels = [...container.querySelectorAll('.agent-map-worktree-label')] + const activeGroup = labels.find((label) => label.textContent === 'Agent map')?.parentElement + const quietGroup = labels.find((label) => label.textContent === 'Quiet result')?.parentElement + + expect(activeGroup).toHaveClass('is-visible') + expect(quietGroup).not.toHaveClass('is-visible') + }) + + it('shows unseen completions as Done and acknowledged completions as Idle by default', () => { + const seenResult = card({ + paneKey: 'seen', + conversationName: 'Seen result', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: false + }) + const newResult = card({ + paneKey: 'new', + conversationName: 'New result', + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 2 * 60_000, + unseen: true + }) + renderMap([card(), seenResult, newResult]) + + expect(screen.getByRole('button', { name: /Agent alpha/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /New result/ })).toHaveClass('fleet-status-done') + expect(screen.getByRole('button', { name: /Seen result/ })).toHaveClass('fleet-status-idle') + }) + + it('hides the states the toolbar filter has muted', () => { + const done = card({ + paneKey: 'done', + conversationName: 'Done agent', + unseen: true, + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000 + }) + renderMap([card(), done], { enabledStates: new Set(['done']) }) + + expect(screen.queryByRole('button', { name: /Agent alpha/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Done agent/ })).toBeInTheDocument() + }) + + it('preserves the viewport when filters temporarily empty the map', () => { + const agent = card() + const all = new Set(['attention', 'working', 'done', 'idle']) + const view = renderMap([agent], { enabledStates: all }) + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + const viewBox = view.container + .querySelector('.agent-map-canvas > svg')! + .getAttribute('viewBox') + + const onOpenTerminal = vi.fn() + view.rerender( + (['done'])} + /> + ) + expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument() + view.rerender( + + ) + + expect(view.container.querySelector('.agent-map-canvas > svg')).toHaveAttribute( + 'viewBox', + viewBox + ) + }) + + it('preserves the viewport through an empty source snapshot', () => { + const agent = card() + const view = renderMap([agent]) + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + const viewBox = view.container + .querySelector('.agent-map-canvas > svg')! + .getAttribute('viewBox') + + view.rerender() + expect(view.container.querySelector('.agent-map-canvas > svg')).not.toBeInTheDocument() + view.rerender() + + expect(view.container.querySelector('.agent-map-canvas > svg')).toHaveAttribute( + 'viewBox', + viewBox + ) + }) + + it('aggregates only acknowledged idle results without repacking topology', () => { + const results = Array.from({ length: 5 }, (_, index) => + card({ + paneKey: `done-${index}`, + conversationName: `Result ${index}`, + bucket: 'done', + dotState: 'done', + finishedAt: NOW - 60_000, + unseen: true + }) + ) + const view = renderMap(results) + const { container } = view + + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(5) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(0) + view.rerender( + ({ ...result, unseen: false }))} + now={NOW} + onOpenTerminal={vi.fn()} + /> + ) + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(0) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(1) + + view.rerender( + ({ ...result, unseen: false }))} + now={NOW} + selectedPaneKey="done-0" + onOpenTerminal={vi.fn()} + /> + ) + expect(container.querySelectorAll('[data-agent-map-agent]')).toHaveLength(5) + expect(container.querySelectorAll('.agent-map-aggregate-node')).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.tsx new file mode 100644 index 00000000000..4e9c3c413ca --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMap.tsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { + DashboardCard, + DashboardCardHostKind, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs, + DashboardWorkspace +} from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import type { TuiAgent } from '../../../../shared/types' +import { AgentMapCanvas, type AgentMapCanvasHandle } from './AgentMapCanvas' +import { + filterAgentMapCards, + type AgentMapState, + type AgentMapHostFilter +} from './agent-map-filter' +import { updateAgentMapLayout, type AgentMapLayoutCache } from './agent-map-layout' +import './agent-map.css' + +type AgentMapProps = { + cards: DashboardCard[] + workspaces?: DashboardWorkspace[] + repoIconsByRepoId?: Record + now: number + className?: string + compact?: boolean + selectedPaneKey?: string | null + /** Owned by the board so the shared toolbar filter can drive it. Defaults to + * every state, i.e. the map shows whatever it is handed. */ + enabledStates?: ReadonlySet + launchableAgentsByWorktreeId?: Record + workspaceContextMenusEnabled?: boolean + onWorkspaceContextMenuOpenChange?: (open: boolean) => void + onOpenTerminal: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +const HOST_FILTERS: AgentMapHostFilter[] = ['all', 'local', 'ssh', 'wsl', 'remote'] +const ALL_AGENT_STATES: ReadonlySet = new Set([ + 'attention', + 'working', + 'done', + 'idle' +]) +const EMPTY_WORKSPACES: DashboardWorkspace[] = [] + +function hostFilterLabel(filter: AgentMapHostFilter): string { + switch (filter) { + case 'all': + return translate('dashboardPopout.map.host.all', 'All hosts') + case 'local': + return translate('dashboardPopout.map.host.local', 'Local') + case 'ssh': + return translate('dashboardPopout.map.host.ssh', 'SSH') + case 'wsl': + return translate('dashboardPopout.map.host.wsl', 'WSL') + case 'remote': + return translate('dashboardPopout.map.host.remote', 'Remote') + } +} + +export function AgentMap({ + cards, + workspaces = EMPTY_WORKSPACES, + repoIconsByRepoId, + now, + className, + compact = false, + selectedPaneKey = null, + enabledStates = ALL_AGENT_STATES, + launchableAgentsByWorktreeId, + workspaceContextMenusEnabled = false, + onWorkspaceContextMenuOpenChange, + onOpenTerminal, + onSpawnAgent, + onSleepWorkspace +}: AgentMapProps): React.JSX.Element { + const canvasRef = useRef(null) + const layoutCacheRef = useRef(null) + const [hostFilter, setHostFilter] = useState('all') + const hostCounts = useMemo(() => { + const counts: Record = { + local: 0, + ssh: 0, + wsl: 0, + remote: 0 + } + for (const card of cards) { + counts[card.hostKind ?? 'local'] += 1 + } + for (const workspace of workspaces) { + counts[workspace.hostKind] += 1 + } + return counts + }, [cards, workspaces]) + const visibleCards = useMemo( + () => + filterAgentMapCards({ + cards, + enabledStates, + hostFilter + }), + [cards, enabledStates, hostFilter] + ) + const visibleWorkspaces = useMemo( + () => + hostFilter === 'all' + ? workspaces + : workspaces.filter((workspace) => workspace.hostKind === hostFilter), + [hostFilter, workspaces] + ) + const layoutResult = useMemo( + () => updateAgentMapLayout(layoutCacheRef.current, visibleCards, now, visibleWorkspaces), + [visibleCards, visibleWorkspaces, now] + ) + useEffect(() => { + layoutCacheRef.current = layoutResult.cache + }, [layoutResult.cache]) + const layout = layoutResult.layout + + return ( +
+
+
+ + {translate( + 'dashboardPopout.map.filters.canvasSummary', + '{{shown}} of {{total}} agents shown', + { + shown: visibleCards.length, + total: cards.length + } + )} + + {!compact ? ( +
+ {HOST_FILTERS.filter((option) => option === 'all' || hostCounts[option] > 0).map( + (option) => ( + + ) + )} +
+ ) : null} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx new file mode 100644 index 00000000000..7ec41a3d8b4 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.performance.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' + +const ringRender = vi.hoisted(() => vi.fn()) +vi.mock('./AgentMapWorktreeRingNode', () => ({ + AgentMapWorktreeRingNode: ({ worktree }: { worktree: { id: string } }) => { + ringRender(worktree.id) + return + } +})) + +import { AgentMap } from './AgentMap' + +const NOW = 2_000_000_000 +const CARD: DashboardCard = { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Measure map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Performance', + startedAt: NOW - 1_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false +} + +describe('AgentMapCanvas pointer performance', () => { + const frames: FrameRequestCallback[] = [] + + beforeEach(() => { + frames.length = 0 + ringRender.mockClear() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 800, + bottom: 560, + width: 800, + height: 560, + toJSON: () => ({}) + }) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('presents the map as a non-selectable panning surface', () => { + const { container } = render() + const svg = container.querySelector('.agent-map-canvas > svg')! + + expect(svg).toHaveClass('cursor-grab', 'touch-none', 'select-none', 'active:cursor-grabbing') + }) + + it('coalesces drag frames without rerendering worktree nodes', () => { + const { container } = render() + const svg = container.querySelector('.agent-map-canvas > svg')! + Object.assign(svg, { + setPointerCapture: vi.fn(), + releasePointerCapture: vi.fn() + }) + expect(ringRender).toHaveBeenCalledTimes(1) + + fireEvent.pointerDown(svg, { pointerId: 2, button: 2, clientX: 20, clientY: 20 }) + expect(svg.setPointerCapture).not.toHaveBeenCalled() + + fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 60, clientY: 20 }) + expect(frames).toHaveLength(1) + + act(() => frames.shift()?.(0)) + expect(ringRender).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx new file mode 100644 index 00000000000..33cd767cc6a --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -0,0 +1,415 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' +import { translate } from '@/i18n/i18n' +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' +import type { + DashboardCard, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import type { TuiAgent } from '../../../../shared/types' +import { + AGENT_MAP_AGENT_RADIUS, + type AgentMapAgentNode, + type AgentMapProjectRing, + type AgentMapLayout +} from './agent-map-layout' +import { AgentMapScene } from './AgentMapScene' +import { AgentMapViewportControls } from './AgentMapViewportControls' +import { + agentMapAgents, + navigableAgentMapAgents, + nextDirectionalAgent +} from './agent-map-navigation' +import type { AgentMapViewport } from './agent-map-viewport-transition' +import { useAgentMapContextMenus } from './useAgentMapContextMenus' +import { useAgentMapCanvasSize } from './useAgentMapCanvasSize' +import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus' +import { useAgentMapViewportTransition } from './useAgentMapViewportTransition' + +const MIN_ZOOM = 0.7 +const MAX_ZOOM = 24 +const AGENT_FOCUS_DURATION_MS = 240 +const AGENT_FOCUS_RADIUS_PX = 24 + +type Point = { x: number; y: number } + +export type AgentMapCanvasHandle = { + fit: () => void + focusProject: (project: AgentMapProjectRing) => void +} + +type AgentMapCanvasProps = { + layout: AgentMapLayout + repoIconsByRepoId?: Record + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgentsByWorktreeId?: Record + workspaceContextMenusEnabled?: boolean + onWorkspaceContextMenuOpenChange?: (open: boolean) => void + onSelectAgent: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.max(minimum, Math.min(maximum, value)) +} + +function agentFocusZoom(layout: AgentMapLayout, width: number, height: number): number { + const aspect = width / Math.max(1, height) + const baseWidth = Math.max(layout.width, layout.height * aspect) + return clamp( + Math.max( + 2, + (baseWidth * AGENT_FOCUS_RADIUS_PX) / (Math.max(1, width) * AGENT_MAP_AGENT_RADIUS) + ), + MIN_ZOOM, + MAX_ZOOM + ) +} + +export const AgentMapCanvas = forwardRef( + function AgentMapCanvas( + { + layout, + repoIconsByRepoId, + selectedPaneKey, + allowAggregation, + launchableAgentsByWorktreeId, + workspaceContextMenusEnabled = false, + onWorkspaceContextMenuOpenChange, + onSelectAgent, + onSpawnAgent, + onSleepWorkspace + }, + forwardedRef + ): React.JSX.Element { + const containerRef = useRef(null) + const svgRef = useRef(null) + const nodeRefs = useRef(new Map()) + const dragRef = useRef<{ + pointerId: number + point: Point + center: Point + worldPerPixelX: number + worldPerPixelY: number + } | null>(null) + const viewportFrameRef = useRef(null) + const pendingViewportRef = useRef(null) + const interactionBoundsRef = useRef(null) + const hasShownProjectsRef = useRef(layout.projects.length > 0) + const clearInteractionBounds = useCallback(() => { + interactionBoundsRef.current = null + }, []) + const size = useAgentMapCanvasSize(containerRef, clearInteractionBounds) + const [viewport, setViewport] = useState({ + center: { x: layout.width / 2, y: layout.height / 2 }, + zoom: 1 + }) + const prefersReducedMotion = usePrefersReducedMotion() + const viewportRef = useRef(viewport) + const { contextMenus, onOpenProjectContextMenu, onOpenWorkspaceContextMenu } = + useAgentMapContextMenus({ + enabled: workspaceContextMenusEnabled, + launchableAgentsByWorktreeId, + onOpenChange: onWorkspaceContextMenuOpenChange, + onSpawnAgent, + onSleepWorkspace + }) + const { center, zoom } = viewport + const agents = useMemo(() => agentMapAgents(layout), [layout]) + const navigableAgents = useMemo( + () => navigableAgentMapAgents(layout, zoom, allowAggregation, selectedPaneKey), + [allowAggregation, layout, selectedPaneKey, zoom] + ) + const hasProjects = layout.projects.length > 0 + const aspect = size.width / Math.max(1, size.height) + const baseWidth = Math.max(layout.width, layout.height * aspect) + const baseHeight = baseWidth / aspect + const viewWidth = baseWidth / zoom + const viewHeight = baseHeight / zoom + const mapScale = size.width / viewWidth + const labelScale = Math.max(1, 1 / mapScale) + const viewBox = `${center.x - viewWidth / 2} ${center.y - viewHeight / 2} ${viewWidth} ${viewHeight}` + const focusZoom = agentFocusZoom(layout, size.width, size.height) + const resolveFocusZoom = useCallback((): number => { + const bounds = containerRef.current?.getBoundingClientRect() + return bounds && bounds.width > 0 && bounds.height > 0 + ? agentFocusZoom(layout, bounds.width, bounds.height) + : focusZoom + }, [focusZoom, layout]) + + const commitViewport = useCallback((next: AgentMapViewport): void => { + viewportRef.current = next + pendingViewportRef.current = null + interactionBoundsRef.current = null + setViewport(next) + }, []) + const { animate: animateViewport, stop: stopViewportTransition } = + useAgentMapViewportTransition({ + durationMs: AGENT_FOCUS_DURATION_MS, + reducedMotion: prefersReducedMotion, + onFrame: commitViewport + }) + useAgentMapSelectedFocus({ + agents, + selectedPaneKey, + viewportRef, + resolveFocusZoom, + animateViewport, + stopViewportTransition + }) + const applyViewport = useCallback( + (next: AgentMapViewport): void => { + stopViewportTransition() + commitViewport(next) + }, + [commitViewport, stopViewportTransition] + ) + const scheduleViewport = useCallback( + (next: AgentMapViewport): void => { + stopViewportTransition() + viewportRef.current = next + pendingViewportRef.current = next + if (viewportFrameRef.current !== null) { + return + } + viewportFrameRef.current = requestAnimationFrame(() => { + viewportFrameRef.current = null + interactionBoundsRef.current = null + const pending = pendingViewportRef.current + pendingViewportRef.current = null + if (pending) { + setViewport(pending) + } + }) + }, + [stopViewportTransition] + ) + const fit = useCallback((): void => { + applyViewport({ center: { x: layout.width / 2, y: layout.height / 2 }, zoom: 1 }) + }, [applyViewport, layout.height, layout.width]) + const focusProject = useCallback( + (project: AgentMapProjectRing): void => { + const projectWidth = project.radius * 2.5 + const projectHeight = project.radius * 2.5 + applyViewport({ + center: { x: project.x, y: project.y }, + zoom: clamp( + Math.min(baseWidth / projectWidth, baseHeight / projectHeight), + MIN_ZOOM, + MAX_ZOOM + ) + }) + }, + [applyViewport, baseHeight, baseWidth] + ) + useImperativeHandle(forwardedRef, () => ({ fit, focusProject }), [fit, focusProject]) + + useEffect(() => { + if (hasProjects && !hasShownProjectsRef.current) { + hasShownProjectsRef.current = true + fit() + } + }, [fit, hasProjects]) + + useEffect( + () => () => { + if (viewportFrameRef.current !== null) { + cancelAnimationFrame(viewportFrameRef.current) + } + }, + [] + ) + + const handleAgentKeyDown = useCallback( + (event: React.KeyboardEvent, agent: AgentMapAgentNode): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onSelectAgent(agent.card) + return + } + const direction = + event.key === 'ArrowLeft' + ? { x: -1, y: 0 } + : event.key === 'ArrowRight' + ? { x: 1, y: 0 } + : event.key === 'ArrowUp' + ? { x: 0, y: -1 } + : event.key === 'ArrowDown' + ? { x: 0, y: 1 } + : null + if (!direction) { + return + } + event.preventDefault() + const next = nextDirectionalAgent(agent, navigableAgents, direction) + nodeRefs.current.get(next?.card.paneKey ?? '')?.focus() + }, + [navigableAgents, onSelectAgent] + ) + + const zoomAt = useCallback( + (nextZoom: number, clientX?: number, clientY?: number): void => { + const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM) + if (clientX === undefined || clientY === undefined) { + applyViewport({ ...viewportRef.current, zoom: clampedZoom }) + return + } + const bounds = + interactionBoundsRef.current ?? svgRef.current?.getBoundingClientRect() ?? null + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + applyViewport({ ...viewportRef.current, zoom: clampedZoom }) + return + } + interactionBoundsRef.current = bounds + const current = viewportRef.current + const currentWidth = baseWidth / current.zoom + const currentHeight = baseHeight / current.zoom + const anchorX = + current.center.x - + currentWidth / 2 + + ((clientX - bounds.left) / bounds.width) * currentWidth + const anchorY = + current.center.y - + currentHeight / 2 + + ((clientY - bounds.top) / bounds.height) * currentHeight + const nextWidth = baseWidth / clampedZoom + const nextHeight = baseHeight / clampedZoom + const xRatio = (clientX - bounds.left) / bounds.width + const yRatio = (clientY - bounds.top) / bounds.height + scheduleViewport({ + center: { + x: anchorX - (xRatio - 0.5) * nextWidth, + y: anchorY - (yRatio - 0.5) * nextHeight + }, + zoom: clampedZoom + }) + }, + [applyViewport, baseHeight, baseWidth, scheduleViewport] + ) + + useEffect(() => { + if (!hasProjects) { + return + } + const svg = svgRef.current + if (!svg) { + return + } + const handleWheel = (event: WheelEvent): void => { + event.preventDefault() + zoomAt( + viewportRef.current.zoom * Math.exp(-event.deltaY * 0.0015), + event.clientX, + event.clientY + ) + } + svg.addEventListener('wheel', handleWheel, { passive: false }) + return () => svg.removeEventListener('wheel', handleWheel) + }, [hasProjects, zoomAt]) + + return ( +
+ {!hasProjects ? ( +
+ {translate('dashboardPopout.map.empty', 'No agents match the current filters.')} +
+ ) : ( + { + if (event.button !== 0) { + return + } + if ( + (event.target as Element).closest( + '[data-agent-map-agent], .agent-map-worktree-ring' + ) + ) { + return + } + const bounds = event.currentTarget.getBoundingClientRect() + if (bounds.width <= 0 || bounds.height <= 0) { + return + } + const current = viewportRef.current + dragRef.current = { + pointerId: event.pointerId, + point: { x: event.clientX, y: event.clientY }, + center: current.center, + worldPerPixelX: baseWidth / current.zoom / bounds.width, + worldPerPixelY: baseHeight / current.zoom / bounds.height + } + event.currentTarget.setPointerCapture(event.pointerId) + }} + onPointerMove={(event) => { + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) { + return + } + scheduleViewport({ + center: { + x: drag.center.x - (event.clientX - drag.point.x) * drag.worldPerPixelX, + y: drag.center.y - (event.clientY - drag.point.y) * drag.worldPerPixelY + }, + zoom: viewportRef.current.zoom + }) + }} + onPointerUp={(event) => { + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null + event.currentTarget.releasePointerCapture(event.pointerId) + } + }} + onPointerCancel={(event) => { + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null + } + }} + > + + + )} + + zoomAt(viewportRef.current.zoom * 1.25)} + onZoomOut={() => zoomAt(viewportRef.current.zoom / 1.25)} + /> + {contextMenus} +
+ ) + } +) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx new file mode 100644 index 00000000000..ba653e7cd59 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenu.tsx @@ -0,0 +1,114 @@ +import { useEffect, useMemo, useRef } from 'react' +import { Plus } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { useAppStore } from '@/store' +import { getRepoHeaderCreateState } from '@/components/sidebar/repo-header-create-state' +import { translate } from '@/i18n/i18n' + +const FOLDER_PROJECT_PREFIX = 'folder-workspace:' + +export type AgentMapProjectContextMenuRequest = { + id: number + projectId: string + clientX: number + clientY: number +} + +type AgentMapProjectContextMenuProps = { + request: AgentMapProjectContextMenuRequest + onOpenChange?: (open: boolean) => void +} + +export function AgentMapProjectContextMenu({ + request, + onOpenChange +}: AgentMapProjectContextMenuProps): React.JSX.Element | null { + const triggerRef = useRef(null) + const repos = useAppStore((state) => state.repos) + const projectGroups = useAppStore((state) => state.projectGroups) + const target = useMemo(() => { + if (request.projectId.startsWith(FOLDER_PROJECT_PREFIX)) { + const groupId = request.projectId.slice(FOLDER_PROJECT_PREFIX.length) + const groups = projectGroups.filter((group) => group.id === groupId) + return groups.length === 1 ? { kind: 'folder' as const, group: groups[0] } : null + } + const owners = repos.filter((repo) => repo.id === request.projectId) + return owners.length === 1 ? { kind: 'repo' as const, repo: owners[0] } : null + }, [projectGroups, repos, request.projectId]) + const repo = target?.kind === 'repo' ? target.repo : null + const sshStatus = useAppStore((state) => + repo?.connectionId ? (state.sshConnectionStates.get(repo.connectionId)?.status ?? null) : null + ) + const openModal = useAppStore((state) => state.openModal) + + useEffect(() => { + if (!target) { + onOpenChange?.(false) + return + } + triggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: request.clientX, + clientY: request.clientY, + button: 2 + }) + ) + }, [onOpenChange, request, target]) + + if (!target) { + return null + } + const label = target.kind === 'repo' ? target.repo.displayName : target.group.name + const createState = + target.kind === 'repo' + ? getRepoHeaderCreateState({ repo: target.repo, label, sshStatus }) + : { + disabled: false, + tooltip: translate( + 'auto.components.sidebar.repo.header.create.state.62e71f2d5d', + 'Create workspace for {{value0}}', + { value0: label } + ), + ariaLabel: translate( + 'auto.components.sidebar.repo.header.create.state.62e71f2d5d', + 'Create workspace for {{value0}}', + { value0: label } + ) + } + + return ( +
+ + + + + + {label} + { + openModal( + 'new-workspace-composer', + target.kind === 'repo' + ? { initialRepoId: target.repo.id, telemetrySource: 'sidebar' } + : { initialProjectGroupId: target.group.id, telemetrySource: 'sidebar' } + ) + }} + > + + {createState.tooltip} + + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx new file mode 100644 index 00000000000..ff81fdaca4f --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectContextMenuLoader.tsx @@ -0,0 +1,29 @@ +import { Suspense } from 'react' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import type { AgentMapProjectContextMenuRequest } from './AgentMapProjectContextMenu' + +const AgentMapProjectContextMenu = lazyWithRetry( + () => + import('./AgentMapProjectContextMenu').then((module) => ({ + default: module.AgentMapProjectContextMenu + })), + { reloadKey: 'agent-map-project-context-menu' } +) + +type AgentMapProjectContextMenuLoaderProps = { + request: AgentMapProjectContextMenuRequest + onOpenChange?: (open: boolean) => void +} + +export function AgentMapProjectContextMenuLoader({ + request, + onOpenChange +}: AgentMapProjectContextMenuLoaderProps): React.JSX.Element { + return ( + + + + ) +} + +export type { AgentMapProjectContextMenuRequest } diff --git a/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx new file mode 100644 index 00000000000..5cbf41b7abb --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapProjectLabel.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { AgentMapLayout } from './agent-map-layout' +import { AgentMapScene } from './AgentMapScene' + +const LAYOUT: AgentMapLayout = { + projects: [ + { + id: 'repo-1', + name: 'Orca', + x: 120, + y: 120, + radius: 96, + worktrees: [], + agentCount: 1 + } + ], + width: 240, + height: 240, + topologyKey: 'repo-1' +} + +describe('AgentMapScene project labels', () => { + it('renders the configured repository image next to its name', () => { + const { container } = render( + + + + ) + + const label = container.querySelector('.agent-map-project-label')! + expect(label).toHaveTextContent('ORCA') + expect(label).toHaveClass('agent-map-project-label') + expect(label.querySelector('.agent-map-project-name')).toHaveTextContent('ORCA') + expect(label.querySelector('img')).toHaveAttribute('src', 'data:image/png;base64,AAAA') + expect(label.firstElementChild?.querySelector('img')).toBeInTheDocument() + expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('x', '-48') + expect(container.querySelector('.agent-map-project-label-frame')).toHaveAttribute('width', '96') + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx new file mode 100644 index 00000000000..2719f4f7921 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -0,0 +1,243 @@ +import { memo, useCallback, useMemo, useState, type MutableRefObject } from 'react' +import { RepoIconGlyph } from '@/components/repo/repo-icon' +import { translate } from '@/i18n/i18n' +import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import type { TuiAgent } from '../../../../shared/types' +import type { + AgentMapAgentNode, + AgentMapLayout, + AgentMapProjectRing, + AgentMapWorktreeRing +} from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' +import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' +import { AgentMapWorktreeLabel } from './AgentMapWorktreeLabel' +import { AgentMapWorktreeRingNode } from './AgentMapWorktreeRingNode' + +type AgentMapSceneProps = { + layout: AgentMapLayout + repoIconsByRepoId?: Record + zoom: number + labelScale: number + mapScale: number + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgentsByWorktreeId?: Record + nodeRefs: MutableRefObject> + onSelectAgent: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onOpenProjectContextMenu?: ( + event: React.MouseEvent, + project: AgentMapProjectRing + ) => void + onOpenWorkspaceContextMenu?: ( + event: React.MouseEvent, + worktree: AgentMapWorktreeRing + ) => void + onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void +} + +function worktreeLineagePath(parent: AgentMapWorktreeRing, child: AgentMapWorktreeRing): string { + const startY = parent.y + parent.radius + const endY = child.y - child.radius + const branchY = (startY + endY) / 2 + return `M ${parent.x} ${startY} C ${parent.x} ${branchY} ${child.x} ${branchY} ${child.x} ${endY}` +} + +type VisibleAgentLocation = { + agent: AgentMapAgentNode + worktreeId: string +} + +function agentLineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string { + const dx = child.x - parent.x + const dy = child.y - parent.y + const distance = Math.hypot(dx, dy) + if (distance === 0) { + return `M ${parent.x} ${parent.y}` + } + const unitX = dx / distance + const unitY = dy / distance + return `M ${parent.x + unitX * parent.radius} ${parent.y + unitY * parent.radius} L ${child.x - unitX * child.radius} ${child.y - unitY * child.radius}` +} + +/** Memoization keeps pointer panning to one SVG viewBox write, not a map rerender. */ +export const AgentMapScene = memo(function AgentMapScene({ + layout, + repoIconsByRepoId, + zoom, + labelScale, + mapScale, + selectedPaneKey, + allowAggregation, + launchableAgentsByWorktreeId, + nodeRefs, + onSelectAgent, + onSpawnAgent, + onOpenProjectContextMenu, + onOpenWorkspaceContextMenu, + onAgentKeyDown +}: AgentMapSceneProps): React.JSX.Element { + const [activeWorktreeId, setActiveWorktreeId] = useState(null) + const handleLabelActiveChange = useCallback((worktreeId: string, active: boolean): void => { + setActiveWorktreeId((current) => + active ? worktreeId : current === worktreeId ? null : current + ) + }, []) + const visibleLabels = useMemo( + () => selectVisibleAgentMapLabels(layout, labelScale, mapScale), + [labelScale, layout, mapScale] + ) + const visibleAgentsByPaneKey = useMemo(() => { + const agents = new Map() + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey) + if (!selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation)) { + continue + } + for (const agent of worktree.agents) { + agents.set(agent.card.paneKey, { agent, worktreeId: worktree.id }) + } + } + } + return agents + }, [allowAggregation, layout, selectedPaneKey, zoom]) + return ( + <> + {layout.projects.map((project) => { + const worktreesById = new Map(project.worktrees.map((worktree) => [worktree.id, worktree])) + const projectLabelHalfWidth = project.radius * mapScale + const projectCountText = translate( + 'dashboardPopout.map.projectCount', + '{{agents}} agents · {{workspaces}} workspaces', + { agents: project.agentCount, workspaces: project.worktrees.length } + ).toUpperCase() + const crossWorktreeLineage = project.worktrees.flatMap((worktree) => + worktree.agents.flatMap((child) => { + const parent = child.card.parentPaneKey + ? visibleAgentsByPaneKey.get(child.card.parentPaneKey) + : undefined + const childLocation = visibleAgentsByPaneKey.get(child.card.paneKey) + return parent && childLocation && parent.worktreeId !== childLocation.worktreeId + ? [{ parent: parent.agent, child }] + : [] + }) + ) + return ( + + { + event.preventDefault() + event.stopPropagation() + onOpenProjectContextMenu(event, project) + } + : undefined + } + /> + + {project.worktrees.map((child) => { + const parent = child.parentId ? worktreesById.get(child.parentId) : undefined + return !parent || child.y <= parent.y ? null : ( + + ) + })} + + + {crossWorktreeLineage.map(({ parent, child }) => { + const relation = parent.card.parentPaneKey ? 'subagent' : 'orchestration' + return ( + + ) + })} + + {project.worktrees.map((worktree) => ( + + ))} + + {project.worktrees.map((worktree) => ( + + ))} + + + +
+ + + {project.name.toUpperCase()} + +
+
+ {visibleLabels.projectCountIds.has(project.id) ? ( + + {projectCountText} + + ) : null} +
+
+ ) + })} + + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx new file mode 100644 index 00000000000..e2208e0be0d --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapSnapshotWorkspaceMenu.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef } from 'react' +import { Moon, Plus } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { translate } from '@/i18n/i18n' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import type { + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' + +export type AgentMapSnapshotWorkspaceMenuRequest = { + id: number + worktreeId: string + worktreeName: string + launchableAgents: readonly TuiAgent[] + clientX: number + clientY: number +} + +type AgentMapSnapshotWorkspaceMenuProps = { + request: AgentMapSnapshotWorkspaceMenuRequest + onOpenChange?: (open: boolean) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +/** + * The workspace right-click menu for surfaces without the app store — the + * pop-out window. Its actions are relayed to the main renderer, so it offers + * only what a snapshot can describe, not the full sidebar menu. + */ +export function AgentMapSnapshotWorkspaceMenu({ + request, + onOpenChange, + onSpawnAgent, + onSleepWorkspace +}: AgentMapSnapshotWorkspaceMenuProps): React.JSX.Element { + const triggerRef = useRef(null) + useEffect(() => { + triggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: request.clientX, + clientY: request.clientY, + button: 2 + }) + ) + }, [request]) + + return ( +
+ + + + + + {request.worktreeName} + {onSpawnAgent ? ( + + + + {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} + + + {request.launchableAgents.map((agent) => ( + onSpawnAgent({ worktreeId: request.worktreeId, agent })} + > + + {getAgentLabel(agent)} + + ))} + + + ) : null} + {onSpawnAgent && onSleepWorkspace ? : null} + {onSleepWorkspace ? ( + onSleepWorkspace({ worktreeId: request.worktreeId })}> + + {translate('dashboardPopout.map.sleepWorkspace', 'Sleep')} + + ) : null} + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx b/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx new file mode 100644 index 00000000000..6e035dca692 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapViewportControls.tsx @@ -0,0 +1,47 @@ +import { Focus, Minus, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' + +type AgentMapViewportControlsProps = { + zoom: number + onFit: () => void + onZoomIn: () => void + onZoomOut: () => void +} + +export function AgentMapViewportControls({ + zoom, + onFit, + onZoomIn, + onZoomOut +}: AgentMapViewportControlsProps): React.JSX.Element { + return ( +
+ + + {Math.round(zoom * 100)}% + + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts new file mode 100644 index 00000000000..7275b3d274e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.boundary.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +function source(file: string): string { + return readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout', file), + 'utf8' + ) +} + +describe('Agent Map workspace menu performance boundary', () => { + it('loads store-backed workspace actions only after a ring context request', () => { + const loader = source('AgentMapWorkspaceContextMenuLoader.tsx') + const menu = source('AgentMapWorkspaceContextMenu.tsx') + + expect(loader).toMatch(/import\('\.\/AgentMapWorkspaceContextMenu'\)/) + expect(loader).not.toMatch( + /import\s+\{\s*AgentMapWorkspaceContextMenu\s*\}\s+from\s+['"]\.\/AgentMapWorkspaceContextMenu['"]/ + ) + expect(menu).toMatch(/import\('@\/components\/sidebar\/WorktreeContextMenu'\)/) + expect(menu).not.toMatch( + /import\s+WorktreeContextMenu\s+from\s+['"]@\/components\/sidebar\/WorktreeContextMenu['"]/ + ) + }) + + it('loads store-backed project actions only after a project context request', () => { + const loader = source('AgentMapProjectContextMenuLoader.tsx') + + expect(loader).toMatch(/import\('\.\/AgentMapProjectContextMenu'\)/) + expect(loader).not.toMatch( + /import\s+\{\s*AgentMapProjectContextMenu\s*\}\s+from\s+['"]\.\/AgentMapProjectContextMenu['"]/ + ) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx new file mode 100644 index 00000000000..057a593da7e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.test.tsx @@ -0,0 +1,375 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import type { ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { AgentMap } from './AgentMap' +import * as StoreSelectors from '@/store/selectors' + +const NOW = 2_000_000_000 +const EXECUTION_HOST_ID = 'runtime:env-1' as const +const initialState = useAppStore.getState() + +const repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Orca', + badgeColor: '#000000', + addedAt: NOW, + kind: 'git', + executionHostId: EXECUTION_HOST_ID +} satisfies Repo + +const worktree = { + id: 'worktree-1', + repoId: repo.id, + path: '/repo/worktrees/map', + displayName: 'Agent map', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + branch: 'refs/heads/agent-map', + head: 'abc123', + isBare: false, + isMainWorktree: false, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + hostId: EXECUTION_HOST_ID +} satisfies Worktree + +const collidingLocalWorktree = { + ...worktree, + path: '/local/repo/worktrees/map', + displayName: 'Local agent map', + hostId: 'local' +} satisfies Worktree + +const parentWorktree = { + ...worktree, + id: 'worktree-parent', + path: '/repo/worktrees/parent', + displayName: 'Parent worktree', + branch: 'refs/heads/parent' +} satisfies Worktree + +const folderProjectGroup = { + id: 'group-1', + name: 'Documentation', + parentPath: '/docs', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: NOW, + updatedAt: NOW +} satisfies ProjectGroup + +const card: DashboardCard = { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: repo.id, + worktreeId: worktree.id, + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: repo.displayName, + worktreeName: worktree.displayName, + startedAt: NOW - 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + workspaceKind: 'worktree' +} + +describe('Agent Map workspace context menu', () => { + beforeEach(() => { + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { [repo.id]: [worktree, parentWorktree] }, + detectedWorktreesByRepo: {}, + projectGroups: [], + workspaceStatuses: [{ id: 'todo', label: 'Todo' }] + }) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + vi.restoreAllMocks() + }) + + it('opens the shared sidebar workspace actions from a worktree ring', async () => { + const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById') + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' }), { + clientX: 120, + clientY: 140 + }) + + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + expect(screen.getByText('Update')).toBeInTheDocument() + expect(screen.getByText('Move to Status')).toBeInTheDocument() + expect(screen.getByText('Open in')).toBeInTheDocument() + expect(screen.getByText('Copy Path')).toBeInTheDocument() + expect(screen.getByText('Pin')).toBeInTheDocument() + expect(screen.getByText('Mark Unread')).toBeInTheDocument() + expect(screen.getByText('Sleep')).toBeInTheDocument() + expect(screen.getByText('Delete')).toBeInTheDocument() + expect(useWorktreeById).toHaveBeenCalledWith(worktree.id, EXECUTION_HOST_ID) + }) + + it('deduplicates the same host owner across known and detected worktrees', async () => { + useAppStore.setState({ + detectedWorktreesByRepo: { + [repo.id]: { + repoId: repo.id, + authoritative: true, + source: 'git', + worktrees: [ + { ...worktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } + ] + } + } + }) + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + }) + + it('uses explicit SSH ownership instead of the paired hub repo host', async () => { + const sshHostId = 'ssh:provider-1' as const + const sshWorktree = { ...worktree, hostId: sshHostId } + useAppStore.setState({ worktreesByRepo: { [repo.id]: [sshWorktree] } }) + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + }) + + it('fails closed when bare-ID actions would span multiple execution hosts', async () => { + useAppStore.setState({ + worktreesByRepo: { [repo.id]: [collidingLocalWorktree, worktree] } + }) + const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById') + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + await waitFor(() => + expect(useWorktreeById).toHaveBeenCalledWith(worktree.id, EXECUTION_HOST_ID) + ) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + + act(() => { + useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } }) + }) + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + }) + + it('clears a workspace request whose target disappeared', async () => { + useAppStore.setState({ worktreesByRepo: {} }) + const useWorktreeById = vi.spyOn(StoreSelectors, 'useWorktreeById') + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + await waitFor(() => expect(useWorktreeById).toHaveBeenCalled()) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + act(() => { + useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } }) + }) + + expect(screen.queryByText('Workspace')).not.toBeInTheDocument() + }) + + it('releases the store-backed workspace menu after an ordinary close', async () => { + const getKnownWorktreeById = vi.fn(useAppStore.getState().getKnownWorktreeById) + useAppStore.setState({ getKnownWorktreeById }) + render( + + {}} + /> + + ) + + fireEvent.contextMenu(screen.getByRole('button', { name: 'Open Agent map worktree details' })) + expect(await screen.findByText('Workspace', {}, { timeout: 5_000 })).toBeInTheDocument() + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => expect(screen.queryByText('Workspace')).not.toBeInTheDocument()) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + + getKnownWorktreeById.mockClear() + act(() => { + useAppStore.setState({ agentStatusEpoch: useAppStore.getState().agentStatusEpoch + 1 }) + }) + expect(getKnownWorktreeById).not.toHaveBeenCalled() + }) + + it('keeps shared-menu follow-up overlays mounted through their lifecycle', async () => { + render( + + {}} + /> + + ) + const ring = screen.getByRole('button', { name: 'Open Agent map worktree details' }) + + fireEvent.contextMenu(ring) + const createGroup = await screen.findByText('New group from project', {}, { timeout: 5_000 }) + fireEvent.pointerDown(createGroup, { button: 0 }) + fireEvent.click(createGroup) + expect(await screen.findByRole('dialog', { name: 'New Project Group' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'New Project Group' })).not.toBeInTheDocument() + ) + + fireEvent.contextMenu(ring) + const setParent = await screen.findByText('Set Parent Worktree...', {}, { timeout: 5_000 }) + fireEvent.pointerDown(setParent, { button: 0 }) + fireEvent.click(setParent) + // Candidate rows are virtualized and measure 0 in happy-dom; the mounted + // search input is the picker's lifecycle signal. + expect(await screen.findByPlaceholderText('Search worktrees...')).toBeInTheDocument() + }) + + it('opens the existing worktree composer from a project ring', async () => { + const { container } = render( + + {}} /> + + ) + + fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!, { + clientX: 100, + clientY: 110 + }) + fireEvent.click(await screen.findByText('Create new worktree for Orca', {}, { timeout: 5_000 })) + + expect(useAppStore.getState().activeModal).toBe('new-workspace-composer') + expect(useAppStore.getState().modalData).toEqual({ + initialRepoId: repo.id, + telemetrySource: 'sidebar' + }) + }) + + it('opens the folder-workspace composer from a synthetic project ring', async () => { + useAppStore.setState({ projectGroups: [folderProjectGroup] }) + const folderCard = { + ...card, + repoId: `folder-workspace:${folderProjectGroup.id}`, + repoName: folderProjectGroup.name, + worktreeId: 'folder:folder-1', + worktreeName: 'Docs', + workspaceKind: 'folder' as const + } + const { container } = render( + + {}} + /> + + ) + + fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!) + fireEvent.click( + await screen.findByText('Create workspace for Documentation', {}, { timeout: 5_000 }) + ) + + expect(useAppStore.getState().modalData).toEqual({ + initialProjectGroupId: folderProjectGroup.id, + telemetrySource: 'sidebar' + }) + }) + + it('clears an ambiguous project request instead of choosing a repo host', async () => { + useAppStore.setState({ + repos: [repo, { ...repo, path: '/local/repo', executionHostId: 'local' }] + }) + const { container } = render( + + {}} /> + + ) + + fireEvent.contextMenu(container.querySelector('[data-agent-map-project]')!) + await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0))) + expect(screen.queryByText('Create new worktree for Orca')).not.toBeInTheDocument() + + act(() => { + useAppStore.setState({ repos: [repo] }) + }) + expect(screen.queryByText('Create new worktree for Orca')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx new file mode 100644 index 00000000000..1ac20df44d2 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenu.tsx @@ -0,0 +1,167 @@ +import { Suspense, useEffect, useMemo, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import { useAppStore } from '@/store' +import { useWorktreeById } from '@/store/selectors' +import type { AppState } from '@/store/types' +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +const WorktreeContextMenu = lazyWithRetry( + () => import('@/components/sidebar/WorktreeContextMenu'), + { reloadKey: 'agent-map-worktree-context-menu' } +) + +export type AgentMapWorkspaceContextMenuRequest = { + id: number + worktreeId: string + executionHostId?: ExecutionHostId + clientX: number + clientY: number + altKey: boolean +} + +type AgentMapWorkspaceContextMenuProps = { + request: AgentMapWorkspaceContextMenuRequest | null + onOpenChange?: (open: boolean) => void + onLifecycleComplete?: () => void +} + +function countWorkspaceOwners( + worktreeId: string | null, + state: Pick< + AppState, + 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' | 'repos' + > +): number { + if (!worktreeId) { + return 0 + } + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + return new Set( + state.folderWorkspaces + .filter((workspace) => workspace.id === scope.folderWorkspaceId) + .map( + (workspace) => + normalizeExecutionHostId(workspace.executionHostId) ?? + (workspace.connectionId ? toSshExecutionHostId(workspace.connectionId) : 'local') + ) + ).size + } + const repoOwnerIdsByRepoId = new Map>() + for (const repo of state.repos) { + const ownerId = getRepoExecutionHostId(repo) + const owners = repoOwnerIdsByRepoId.get(repo.id) + if (owners) { + owners.add(ownerId) + } else { + repoOwnerIdsByRepoId.set(repo.id, new Set([ownerId])) + } + } + const ownerIds = new Set() + const addOwner = (worktree: { repoId: string; hostId?: ExecutionHostId }): void => { + const directOwner = normalizeExecutionHostId(worktree.hostId) + if (directOwner) { + ownerIds.add(directOwner) + return + } + const repoOwnerIds = repoOwnerIdsByRepoId.get(worktree.repoId) + if (!repoOwnerIds) { + ownerIds.add('local') + return + } + for (const ownerId of repoOwnerIds) { + ownerIds.add(ownerId) + } + } + for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktree of worktrees) { + if (worktree.id === worktreeId) { + addOwner(worktree) + } + } + } + for (const result of Object.values(state.detectedWorktreesByRepo)) { + for (const worktree of result.worktrees) { + if (worktree.id === worktreeId) { + addOwner(worktree) + } + } + } + return ownerIds.size +} + +function ContextMenuTrigger({ + request +}: { + request: AgentMapWorkspaceContextMenuRequest +}): React.JSX.Element { + const triggerRef = useRef(null) + useEffect(() => { + triggerRef.current?.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: request.clientX, + clientY: request.clientY, + altKey: request.altKey, + button: 2 + }) + ) + }, [request]) + return +} + +export function AgentMapWorkspaceContextMenu({ + request, + onOpenChange, + onLifecycleComplete +}: AgentMapWorkspaceContextMenuProps): React.JSX.Element | null { + const { worktreesByRepo, detectedWorktreesByRepo, folderWorkspaces, repos } = useAppStore( + useShallow((state) => ({ + worktreesByRepo: state.worktreesByRepo, + detectedWorktreesByRepo: state.detectedWorktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + repos: state.repos + })) + ) + const worktree = useWorktreeById(request?.worktreeId ?? null, request?.executionHostId) + const ownerCount = useMemo( + () => + countWorkspaceOwners(request?.worktreeId ?? null, { + worktreesByRepo, + detectedWorktreesByRepo, + folderWorkspaces, + repos + }), + [detectedWorktreesByRepo, folderWorkspaces, repos, request?.worktreeId, worktreesByRepo] + ) + const unavailable = request !== null && (!worktree || ownerCount !== 1) + useEffect(() => { + if (unavailable) { + onLifecycleComplete?.() + } + }, [onLifecycleComplete, unavailable]) + if (!request || unavailable || !worktree) { + return null + } + return ( +
+ + + + + +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx new file mode 100644 index 00000000000..8ece35ad2ef --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorkspaceContextMenuLoader.tsx @@ -0,0 +1,35 @@ +import { Suspense } from 'react' +import { lazyWithRetry } from '@/lib/lazy-with-retry' +import type { AgentMapWorkspaceContextMenuRequest } from './AgentMapWorkspaceContextMenu' + +const AgentMapWorkspaceContextMenu = lazyWithRetry( + () => + import('./AgentMapWorkspaceContextMenu').then((module) => ({ + default: module.AgentMapWorkspaceContextMenu + })), + { reloadKey: 'agent-map-workspace-context-menu' } +) + +type AgentMapWorkspaceContextMenuLoaderProps = { + request: AgentMapWorkspaceContextMenuRequest + onOpenChange?: (open: boolean) => void + onLifecycleComplete?: () => void +} + +export function AgentMapWorkspaceContextMenuLoader({ + request, + onOpenChange, + onLifecycleComplete +}: AgentMapWorkspaceContextMenuLoaderProps): React.JSX.Element { + return ( + + + + ) +} + +export type { AgentMapWorkspaceContextMenuRequest } diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx new file mode 100644 index 00000000000..76398b29daf --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx @@ -0,0 +1,39 @@ +import { memo } from 'react' +import { translate } from '@/i18n/i18n' +import type { AgentMapWorktreeRing } from './agent-map-layout' + +type AgentMapWorktreeLabelProps = { + worktree: AgentMapWorktreeRing + visible: boolean + active: boolean + labelScale: number + mapScale: number +} + +export const AgentMapWorktreeLabel = memo(function AgentMapWorktreeLabel({ + worktree, + visible, + active, + labelScale, + mapScale +}: AgentMapWorktreeLabelProps): React.JSX.Element { + const showCount = visible && (active || worktree.radius * mapScale >= 80) + const agentCountText = translate( + 'dashboardPopout.map.agentCount', + worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents', + { count: worktree.agents.length } + ) + return ( + + + {worktree.name} + + + {agentCountText} + + + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx new file mode 100644 index 00000000000..9102b5e51c0 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -0,0 +1,370 @@ +import { memo, useState, type MutableRefObject } from 'react' +import { Plus } from 'lucide-react' +import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { translate } from '@/i18n/i18n' +import { AgentIcon, getAgentLabel } from '@/lib/agent-catalog' +import { agentTypeToIconAgent } from '@/lib/agent-status' +import type { DashboardCard, DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import type { + AgentMapAgentNode, + AgentMapProjectRing, + AgentMapWorktreeRing +} from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' +import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status' + +type AgentMapWorktreeRingNodeProps = { + project: AgentMapProjectRing + worktree: AgentMapWorktreeRing + zoom: number + mapScale: number + selectedPaneKey: string | null + allowAggregation: boolean + launchableAgents?: readonly TuiAgent[] + nodeRefs: MutableRefObject> + onSelectAgent: (card: DashboardCard) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onOpenWorkspaceContextMenu?: ( + event: React.MouseEvent, + worktree: AgentMapWorktreeRing + ) => void + onLabelActiveChange: (worktreeId: string, active: boolean) => void + onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void +} + +function formatDuration(minutes: number): string { + if (minutes < 1) { + return translate('dashboardPopout.card.time.justNow', 'just now') + } + if (minutes < 60) { + return translate('dashboardPopout.card.time.minutes', '{{count}}m', { + count: Math.floor(minutes) + }) + } + return translate('dashboardPopout.card.time.hours', '{{count}}h', { + count: Math.floor(minutes / 60) + }) +} + +function lineagePath(parent: AgentMapAgentNode, child: AgentMapAgentNode): string { + const startY = parent.y + parent.radius + const endY = child.y - child.radius + const branchY = (startY + endY) / 2 + return `M ${parent.x} ${startY} L ${parent.x} ${branchY} L ${child.x} ${branchY} L ${child.x} ${endY}` +} + +function agentName(card: DashboardCard): string { + return card.conversationName ?? (card.task.trim() || card.agentType) +} + +export function agentMapAttentionMarkerScale(mapScale: number): number { + const inverseScale = 1 / Math.max(mapScale, 0.001) + return Math.max(1, inverseScale ** 0.72, inverseScale * 0.5) +} + +function WorktreeDetails({ + project, + worktree, + launchableAgents, + onSelectAgent, + onSpawnAgent, + onDone +}: Pick< + AgentMapWorktreeRingNodeProps, + 'project' | 'worktree' | 'launchableAgents' | 'onSelectAgent' | 'onSpawnAgent' +> & { + onDone: () => void +}): React.JSX.Element { + const activeCount = + worktree.statusCounts.working + worktree.statusCounts.blocked + worktree.statusCounts.waiting + return ( + +
+ {project.name} + {worktree.name} + + {translate( + 'dashboardPopout.map.worktreeSummary', + '{{total}} agents · {{active}} active · {{done}} done', + { + count: worktree.agents.length, + defaultValue_one: '{{total}} agent · {{active}} active · {{done}} done', + defaultValue_other: '{{total}} agents · {{active}} active · {{done}} done', + total: worktree.agents.length, + active: activeCount, + done: worktree.statusCounts.done + } + )} + +
+
+

+ {translate('dashboardPopout.map.runningAgents', 'Agents')} +

+
+ {worktree.agents.length === 0 ? ( +

+ {translate('dashboardPopout.map.noWorkspaceAgents', 'No agents in this workspace.')} +

+ ) : ( + worktree.agents.map((agent) => ( + + )) + )} +
+
+ {onSpawnAgent ? ( +
+

+ {translate('dashboardPopout.map.spawnAgent', 'Start a new agent')} +

+ {launchableAgents && launchableAgents.length > 0 ? ( +
+ {launchableAgents.map((agent) => ( + + ))} +
+ ) : ( +

+ {translate('dashboardPopout.map.noLaunchableAgents', 'No enabled agents detected.')} +

+ )} +
+ ) : null} +
+ ) +} + +export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ + project, + worktree, + zoom, + mapScale, + selectedPaneKey, + allowAggregation, + launchableAgents, + nodeRefs, + onSelectAgent, + onSpawnAgent, + onOpenWorkspaceContextMenu, + onLabelActiveChange, + onAgentKeyDown +}: AgentMapWorktreeRingNodeProps): React.JSX.Element { + const [detailsOpen, setDetailsOpen] = useState(false) + const selected = worktree.agents.some((agent) => agent.card.paneKey === selectedPaneKey) + const activeStatus = agentMapWorktreeActiveStatus(worktree.statusCounts) + const aggregate = !selected && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation) + const agentsByPaneKey = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) + + return ( + + onLabelActiveChange(worktree.id, true)} + onPointerLeave={() => onLabelActiveChange(worktree.id, false)} + onFocus={() => onLabelActiveChange(worktree.id, true)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + onLabelActiveChange(worktree.id, false) + } + }} + > + {activeStatus ? ( + + setDetailsOpen(false)} + /> + + ) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx index 6610efe2605..e788378a6db 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.test.tsx @@ -2,27 +2,31 @@ import '@testing-library/jest-dom/vitest' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { DashboardCard, DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot' -import { AgentTerminalDialog } from './AgentTerminalDialog' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { AgentTerminalDialog, AgentTerminalPanel } from './AgentTerminalDialog' // Stub the preview so the assertion is on the props the dialog hands it, with // no xterm / IPC machinery in the way. vi.mock('./AgentTerminalPreview', () => ({ AgentTerminalPreview: ({ ptyId, - terminalInput + terminalInput, + className }: { ptyId: string terminalInput?: DashboardCardTerminalInput | null + className?: string }) => (
) })) @@ -85,4 +89,81 @@ describe('AgentTerminalDialog', () => { expect(screen.getByTestId('preview')).toHaveAttribute('data-terminal-input', 'null') }) + + it('labels acknowledged completions idle without review or pin controls', () => { + render( + {}} + onReveal={() => {}} + /> + ) + + expect(screen.getByText(/Claude · Idle/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Keep visible' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Mark reviewed' })).not.toBeInTheDocument() + expect(screen.getByTestId('preview')).toHaveAttribute('data-pty-id', 'pty-1') + }) + + it('labels unseen completions done', () => { + render( + {}} + onReveal={() => {}} + /> + ) + + expect(screen.getByText(/Claude · Done/)).toBeInTheDocument() + }) + + it('preserves the execution host when revealing a colliding worktree ID', () => { + const onReveal = vi.fn() + render( + {}} + onReveal={onReveal} + /> + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open worktree' })) + expect(onReveal).toHaveBeenCalledWith({ + repoId: 'r1', + worktreeId: 'w1', + executionHostId: 'runtime:env-1', + tabId: 'tab1', + leafId: 'leaf1' + }) + }) + + it('reuses the terminal surface as a non-modal adjacent panel', () => { + render( {}} onReveal={() => {}} />) + + expect(screen.getByRole('dialog', { name: 'wt' })).toHaveAttribute('data-state', 'open') + expect(screen.getByTestId('preview')).toHaveClass('min-h-0', 'flex-1') + expect(document.querySelector('[data-slot="dialog-overlay"]')).not.toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'wt' })).toBeInTheDocument() + }) + + it('lets a nested Radix layer consume Escape before closing the panel', () => { + const onOpenChange = vi.fn() + render( + <> + {}} /> + + Details + Worktree details + + + ) + + fireEvent.keyDown(screen.getByText('Worktree details'), { key: 'Escape' }) + + expect(screen.queryByText('Worktree details')).not.toBeInTheDocument() + expect(onOpenChange).not.toHaveBeenCalled() + + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx index ead43090820..a5eea33adc3 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalDialog.tsx @@ -1,21 +1,21 @@ -import { useCallback } from 'react' +import { useEffect, useId } from 'react' import { SquareArrowOutUpRight, XIcon } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { agentStateLabel } from '@/components/AgentStateDot' -import { Dialog, DialogClose, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardRevealAgentArgs +} from '../../../../shared/dashboard-snapshot' import { AgentTerminalPreview } from './AgentTerminalPreview' import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' /** Routing payload for focusing an agent's pane in the main window. */ -export type AgentRevealArgs = { - repoId: string - worktreeId: string - tabId: string - leafId: string | null -} +export type AgentRevealArgs = DashboardRevealAgentArgs type AgentTerminalDialogProps = { /** The agent shown in the dialog; null renders the dialog closed. */ @@ -26,6 +26,76 @@ type AgentTerminalDialogProps = { onReveal: (args: AgentRevealArgs) => void } +type AgentTerminalFrameProps = Omit & { + card: DashboardCard + title: React.ReactNode + previewClassName?: string +} + +function AgentTerminalFrame({ + card, + title, + previewClassName, + onOpenChange, + onReveal +}: AgentTerminalFrameProps): React.JSX.Element { + const reveal = (): void => { + onReveal({ + repoId: card.repoId, + worktreeId: card.worktreeId, + executionHostId: card.executionHostId, + tabId: card.tabId, + leafId: card.leafId + }) + onOpenChange(false) + } + + return ( + <> +
+ + + + {title} + + {formatAgentTypeLabel(card.agentType)} ·{' '} + {agentStateLabel(dashboardCardDisplayState(card))} + + +
+ {card.ptyId ? ( + + ) : ( +
+ {translate( + 'dashboardPopout.terminal.closed', + "No live terminal — this agent's pane has closed." + )} +
+ )} +
+ +
+ + ) +} + /** * The near-fullscreen live-terminal dialog for one agent. Hosted by the BOARD, * not the card: sending a message flips the agent's bucket, which remounts its @@ -38,18 +108,6 @@ export function AgentTerminalDialog({ onOpenChange, onReveal }: AgentTerminalDialogProps): React.JSX.Element { - const reveal = useCallback(() => { - if (!card) { - return - } - onReveal({ - repoId: card.repoId, - worktreeId: card.worktreeId, - tabId: card.tabId, - leafId: card.leafId - }) - }, [card, onReveal]) - return ( {card ? ( @@ -78,43 +136,68 @@ export function AgentTerminalDialog({ } }} > -
- - - - - {card.worktreeName} - - - {formatAgentTypeLabel(card.agentType)} · {agentStateLabel(card.dotState)} - - - - - {translate('dashboardPopout.terminal.close', 'Close')} - - -
- {card.ptyId ? ( - - ) : ( -
- {translate( - 'dashboardPopout.terminal.closed', - "No live terminal — this agent's pane has closed." - )} -
- )} -
- - - -
+ + {card.worktreeName} + + } + onOpenChange={onOpenChange} + onReveal={onReveal} + /> ) : null}
) } + +export function AgentTerminalPanel({ + card, + onOpenChange, + onReveal, + className +}: Omit & { + card: DashboardCard + className?: string +}): React.JSX.Element { + const titleId = useId() + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if ( + event.key === 'Escape' && + !event.defaultPrevented && + !(event.target instanceof HTMLElement && event.target.closest('.xterm')) + ) { + onOpenChange(false) + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onOpenChange]) + + return ( +
+ + {card.worktreeName} + + } + previewClassName="h-auto min-h-0 flex-1" + onOpenChange={onOpenChange} + onReveal={onReveal} + /> +
+ ) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index 8da354166f7..50d65df9193 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -49,11 +49,13 @@ function clamp(value: number, min: number, max: number): number { */ export function AgentTerminalPreview({ ptyId, - terminalInput = null + terminalInput = null, + className }: { ptyId: string /** Host-input facts relayed with the card; null routes bytes by client OS. */ terminalInput?: DashboardCardTerminalInput | null + className?: string }): React.JSX.Element { const containerRef = useRef(null) const terminalRef = useRef(null) @@ -444,7 +446,10 @@ export function AgentTerminalPreview({ // buffer is. The terminal keeps the pane's true dimensions and is scaled/ // clipped to fit; fitToBox anchors whichever end keeps the cursor in view.
{ptyGone ? ( diff --git a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx index a857b298b50..557036c569a 100644 --- a/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx +++ b/src/renderer/src/components/dashboard-popout/DashboardPopoutRoot.tsx @@ -1,9 +1,9 @@ -import { AgentKanbanBoard } from './AgentKanbanBoard' +import { useEffect, useState } from 'react' +import { AgentKanbanBoard, type AgentDashboardView } from './AgentKanbanBoard' import { useDashboardSnapshot } from './useDashboardSnapshot' type DashboardPopoutRootProps = { - /** The layout requested via popout.html?view=. Only "kanban" exists - * today; unknown views fall back to it. */ + /** The layout requested via popout.html?view=. */ view: string | null } @@ -13,5 +13,9 @@ type DashboardPopoutRootProps = { */ export function DashboardPopoutRoot(_props: DashboardPopoutRootProps): React.JSX.Element { const snapshot = useDashboardSnapshot() - return + const [view, setView] = useState(() => + _props.view === 'map' || _props.view === 'rings' ? 'map' : 'board' + ) + useEffect(() => window.api.dashboard.onViewRequested(setView), []) + return } diff --git a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts index d7a4c132150..14470931ca8 100644 --- a/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts +++ b/src/renderer/src/components/dashboard-popout/agent-board-filtering.ts @@ -1,4 +1,8 @@ -import type { DashboardCard, DashboardCardReview } from '../../../../shared/dashboard-snapshot' +import type { + DashboardCard, + DashboardCardReview, + DashboardWorkspace +} from '../../../../shared/dashboard-snapshot' export type DashboardReviewFilter = DashboardCardReview['state'] | 'none' @@ -36,6 +40,34 @@ function cardSearchText(card: DashboardCard): string { .toLocaleLowerCase() } +function workspaceSearchText(workspace: DashboardWorkspace): string { + return [ + workspace.worktreeName, + workspace.repoName, + workspace.review ? `#${workspace.review.number}` : '' + ] + .filter(Boolean) + .join(' ') + .toLocaleLowerCase() +} + +function workspaceMatchesFilters( + workspace: DashboardWorkspace, + normalizedQuery: string, + filters: DashboardFilters +): boolean { + const reviewState = workspace.review?.state ?? (workspace.hasReview ? null : 'none') + return ( + (normalizedQuery.length === 0 || workspaceSearchText(workspace).includes(normalizedQuery)) && + (filters.projects.length === 0 || filters.projects.includes(workspace.repoId)) && + (filters.workspaceStatuses.length === 0 || + (workspace.workspaceStatusId !== undefined && + filters.workspaceStatuses.includes(workspace.workspaceStatusId))) && + (filters.reviewStates.length === 0 || + (reviewState !== null && filters.reviewStates.includes(reviewState))) + ) +} + export function filterDashboardCards( cards: DashboardCard[], query: string, @@ -56,6 +88,17 @@ export function filterDashboardCards( }) } +export function filterDashboardWorkspaces( + workspaces: DashboardWorkspace[], + query: string, + filters: DashboardFilters +): DashboardWorkspace[] { + const normalizedQuery = query.trim().toLocaleLowerCase() + return workspaces.filter((workspace) => + workspaceMatchesFilters(workspace, normalizedQuery, filters) + ) +} + export function toggleDashboardFilter(values: T[], value: T): T[] { return values.includes(value) ? values.filter((candidate) => candidate !== value) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts b/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts new file mode 100644 index 00000000000..56978357359 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-agent-placement.ts @@ -0,0 +1,54 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' + +const GOLDEN_ANGLE = 2.399963229728653 + +function stableHash(value: string): number { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return hash >>> 0 +} + +export function placeAgentMapAgents({ + worktreeId, + cards, + radius, + agentRadius, + now +}: { + worktreeId: string + cards: DashboardCard[] + radius: number + agentRadius: number + now: number +}): { + card: DashboardCard + x: number + y: number + radius: number + durationMinutes: number + status: DashboardCard['dotState'] +}[] { + const availableRadius = Math.max(0, radius - agentRadius - 6) + const sorted = [...cards].sort((a, b) => + a.paneKey < b.paneKey ? -1 : a.paneKey > b.paneKey ? 1 : 0 + ) + const capacity = Math.ceil(Math.sqrt(Math.max(1, sorted.length))) ** 2 + const angleOffset = (stableHash(worktreeId) / 0xffffffff) * Math.PI * 2 + + return sorted.map((card, index) => { + const orbit = sorted.length === 1 ? 0 : Math.sqrt((index + 0.5) / capacity) * availableRadius + const angle = angleOffset + index * GOLDEN_ANGLE + return { + card, + x: Math.cos(angle) * orbit, + y: Math.sin(angle) * orbit, + radius: agentRadius, + durationMinutes: agentMapDurationMinutes(card, now), + status: agentMapNodeStatus(card) + } + }) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts new file mode 100644 index 00000000000..4269759c99c --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-filter.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { agentMapState, countAgentMapCards, filterAgentMapCards } from './agent-map-filter' + +const NOW = 2_000_000_000 + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'done', + dotState: 'done', + task: '', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + startedAt: NOW - 60_000, + finishedAt: NOW - 30_000, + stateChangedAt: NOW - 30_000, + unseen: false, + hostKind: 'local', + ...overrides + } +} + +describe('agent map filtering', () => { + it('keeps unseen completions done and settles acknowledged completions into idle', () => { + expect(agentMapState(card({ unseen: true }))).toBe('done') + expect(agentMapState(card({ unseen: false }))).toBe('idle') + expect(agentMapState(card({ bucket: 'idle', dotState: 'idle' }))).toBe('idle') + }) + + it('applies state and host filters independently', () => { + const hidden = card({ paneKey: 'hidden', repoId: 'hidden', hostKind: 'ssh', unseen: true }) + const visible = filterAgentMapCards({ + cards: [hidden], + enabledStates: new Set(['done']), + hostFilter: 'ssh' + }) + + expect(visible).toEqual([hidden]) + expect( + filterAgentMapCards({ + cards: [hidden], + enabledStates: new Set(['idle']), + hostFilter: 'ssh' + }) + ).toEqual([]) + expect( + filterAgentMapCards({ + cards: [hidden], + enabledStates: new Set(['done']), + hostFilter: 'local' + }) + ).toEqual([]) + }) + + it('counts all four display states', () => { + const cards = [ + card({ paneKey: 'done-new', unseen: true }), + card({ paneKey: 'done-seen', unseen: false }), + card({ paneKey: 'working', bucket: 'working', dotState: 'working', finishedAt: null }), + card({ paneKey: 'waiting', bucket: 'attention', dotState: 'waiting', finishedAt: null }) + ] + + expect(countAgentMapCards(cards)).toEqual({ + attention: 1, + working: 1, + done: 1, + idle: 1 + }) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-filter.ts b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts new file mode 100644 index 00000000000..3a137ff93bd --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-filter.ts @@ -0,0 +1,48 @@ +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardCardHostKind +} from '../../../../shared/dashboard-snapshot' + +export type AgentMapState = 'attention' | 'working' | 'done' | 'idle' +export type AgentMapHostFilter = 'all' | DashboardCardHostKind +export type AgentMapCounts = Record + +export function agentMapState(card: DashboardCard): AgentMapState { + const state = dashboardCardDisplayState(card) + if (state === 'blocked' || state === 'waiting') { + return 'attention' + } + return state +} + +export function filterAgentMapCards({ + cards, + enabledStates, + hostFilter +}: { + cards: DashboardCard[] + enabledStates: ReadonlySet + hostFilter: AgentMapHostFilter +}): DashboardCard[] { + // Project filtering lives in the shared toolbar filter, which has already + // narrowed these cards. + return cards.filter( + (card) => + (hostFilter === 'all' || (card.hostKind ?? 'local') === hostFilter) && + enabledStates.has(agentMapState(card)) + ) +} + +export function countAgentMapCards(cards: DashboardCard[]): AgentMapCounts { + const counts: AgentMapCounts = { + attention: 0, + working: 0, + done: 0, + idle: 0 + } + for (const card of cards) { + counts[agentMapState(card)] += 1 + } + return counts +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts new file mode 100644 index 00000000000..9403974b62b --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-glow.performance.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +function source(file: string): string { + return readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout', file), + 'utf8' + ) +} + +describe('Agent Map glow performance boundary', () => { + it('uses one conditional SVG halo per active entity without filter effects', () => { + const component = source('AgentMapWorktreeRingNode.tsx') + + expect(component.match(/data-agent-map-worktree-status-glow/g)).toHaveLength(1) + expect(component.match(/data-agent-map-agent-working-glow/g)).toHaveLength(1) + expect(component).not.toMatch(/ { + const css = source('agent-map.css') + const glowRules = css.match(/\.agent-map-(?:worktree-status|agent-working)-glow\s*\{[^}]+\}/gs) + + expect(glowRules).toHaveLength(2) + for (const rule of glowRules ?? []) { + expect(rule).toContain('pointer-events: none') + expect(rule).toContain('vector-effect: non-scaling-stroke') + expect(rule).not.toMatch(/filter:|animation:|transition:/) + } + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts new file mode 100644 index 00000000000..28c71c881a1 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest' +import { selectVisibleAgentMapLabels } from './agent-map-label-declutter' +import type { + AgentMapLayout, + AgentMapProjectRing, + AgentMapStatusCounts, + AgentMapWorktreeRing +} from './agent-map-layout' + +function statusCounts(overrides: Partial = {}): AgentMapStatusCounts { + return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0, ...overrides } +} + +function worktree(overrides: Partial = {}): AgentMapWorktreeRing { + const counts = overrides.statusCounts ?? statusCounts({ working: 1 }) + const total = Object.values(counts).reduce((sum, value) => sum + value, 0) + return { + id: 'worktree-a', + worktreeId: 'worktree-a', + executionHostId: undefined, + name: 'alpha', + workspaceKind: 'worktree', + x: 0, + y: 0, + radius: 62, + // Sparse placeholders keep tests that only care about label-to-label collisions concise. + agents: Array.from({ length: total }) as AgentMapWorktreeRing['agents'], + statusCounts: counts, + quiet: counts.idle === total, + ...overrides + } +} + +function layoutOf( + worktrees: AgentMapWorktreeRing[], + project: Partial = {} +): AgentMapLayout { + return { + projects: [ + { + id: 'project-1', + name: 'orca', + x: 0, + // Parked far above the workspaces so the project label is not itself + // the thing under test unless a case moves it. + y: -4_000, + radius: 100, + worktrees, + agentCount: worktrees.reduce((sum, item) => sum + item.agents.length, 0), + ...project + } + ], + width: 900, + height: 560, + topologyKey: 'test' + } +} + +describe('selectVisibleAgentMapLabels', () => { + it('keeps both labels when they are far enough apart', () => { + const layout = layoutOf([ + worktree({ id: 'a', x: -400, y: 0 }), + worktree({ id: 'b', name: 'beta', x: 400, y: 0 }) + ]) + + const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds].sort()).toEqual(['a', 'b']) + }) + + it('drops the lower-priority label when two would overlap', () => { + // Same anchor point: the two labels are drawn on top of each other. + const layout = layoutOf([ + worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }), + worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) }) + ]) + + const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds]).toEqual(['busy']) + }) + + it('hides a workspace title that would cover an agent', () => { + const coveringAgent = { x: 0, y: -48, radius: 20 } + const covered = worktree({ + id: 'covered', + agents: [coveringAgent] as AgentMapWorktreeRing['agents'] + }) + + const labels = selectVisibleAgentMapLabels(layoutOf([covered]), 1, 1) + + expect(labels.worktreeIds.size).toBe(0) + expect(labels.worktreeAgentSafeIds.size).toBe(0) + }) + + it('keeps a collision-suppressed workspace title safe to reveal away from agents', () => { + const layout = layoutOf([ + worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }), + worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) }) + ]) + + const labels = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...labels.worktreeIds]).toEqual(['busy']) + expect([...labels.worktreeAgentSafeIds].sort()).toEqual(['busy', 'calm']) + }) + + it('lets a blocked workspace outrank a busier neighbour for the surviving label', () => { + const layout = layoutOf([ + worktree({ id: 'blocked', x: 0, y: 0, statusCounts: statusCounts({ blocked: 1 }) }), + worktree({ + id: 'working', + name: 'beta', + x: 0, + y: 0, + statusCounts: statusCounts({ working: 9 }) + }) + ]) + + const { worktreeIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds]).toEqual(['blocked']) + }) + + it('hides all-idle workspace labels until the ring is large on screen', () => { + const idle = worktree({ id: 'idle', x: 0, y: 0, statusCounts: statusCounts({ idle: 2 }) }) + + expect(selectVisibleAgentMapLabels(layoutOf([idle]), 1, 0.5).worktreeIds.size).toBe(0) + expect([...selectVisibleAgentMapLabels(layoutOf([idle]), 1, 1).worktreeIds]).toEqual(['idle']) + }) + + it('drops the project count rather than a workspace name when they collide', () => { + // The workspace ring's name lands on the project's count line. + const layout = layoutOf([worktree({ id: 'a', x: 0, y: 42 })], { + x: 0, + y: 0, + radius: 40, + agentCount: 1 + }) + + const { worktreeIds, projectCountIds } = selectVisibleAgentMapLabels(layout, 1, 1) + + expect([...worktreeIds]).toEqual(['a']) + expect(projectCountIds.size).toBe(0) + }) + + it('keeps the project count when nothing is in its way', () => { + const layout = layoutOf([worktree({ id: 'a', x: 0, y: 0 })]) + + expect([...selectVisibleAgentMapLabels(layout, 1, 1).projectCountIds]).toEqual(['project-1']) + }) + + it('admits more labels as zooming in shrinks their world footprint', () => { + const worktrees = Array.from({ length: 6 }, (_unused, index) => + worktree({ id: `w-${index}`, name: `workspace-${index}`, x: index * 90, y: 0 }) + ) + + const zoomedOut = selectVisibleAgentMapLabels(layoutOf(worktrees), 4, 0.25).worktreeIds + const zoomedIn = selectVisibleAgentMapLabels(layoutOf(worktrees), 1, 1).worktreeIds + + expect(zoomedOut.size).toBeLessThan(zoomedIn.size) + expect(zoomedIn.size).toBe(6) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts new file mode 100644 index 00000000000..020d8453dd2 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -0,0 +1,273 @@ +import type { AgentMapLayout, AgentMapWorktreeRing } from './agent-map-layout' + +/** Metrics for the label styles in agent-map.css. Estimating text extents from + * the character count avoids a per-frame DOM measure of every label. */ +const WORKTREE_LABEL_FONT_PX = 12 +const PROJECT_LABEL_FONT_PX = 13 +const COUNT_FONT_PX = 11 +const GLYPH_WIDTH_RATIO = 0.56 +/** Uppercase project text runs wider per glyph than a mixed-case worktree name. */ +const UPPERCASE_GLYPH_WIDTH_RATIO = 0.66 +const ASCENT_RATIO = 0.8 +const DESCENT_RATIO = 0.2 +const PROJECT_LABEL_ICON_PX = 16 +/** Local-unit breathing room so two labels never appear to touch. */ +const LABEL_GAP_X_PX = 3 +const LABEL_GAP_Y_PX = 1 +const AGENT_LABEL_CLEARANCE_PX = 3 +/** Baselines the scene renders at, relative to each label group's origin. */ +const WORKTREE_LABEL_BASELINE = 18 +const COUNT_BASELINE = 32 +const PROJECT_NAME_TOP = 3 +const PROJECT_NAME_BOTTOM = 21 +/** Past this many candidates the pass stops admitting labels; a map that dense + * is unreadable long before the cap, and this bounds the work. */ +const MAX_LABEL_CANDIDATES = 600 +const DECLUTTER_GRID_PX = 96 + +/** A label box in world units, matching what the scene actually renders. */ +type LabelBox = { + left: number + right: number + top: number + bottom: number +} + +type LabelGrid = Map> + +export type AgentMapVisibleLabels = { + /** Worktree ring ids whose name can render without colliding. */ + worktreeIds: Set + /** Worktree names that hover/focus may reveal without covering an agent. */ + worktreeAgentSafeIds: Set + /** Project ids whose agent/workspace count line still has room. The count is + * the first thing dropped: it repeats what the filter rail already says. */ + projectCountIds: Set +} + +function textWidth(text: string, fontPx: number, ratio = GLYPH_WIDTH_RATIO): number { + return text.length * fontPx * ratio +} + +export function agentMapWorktreeLabelWidth(text: string): number { + return textWidth(text, WORKTREE_LABEL_FONT_PX) +} + +export function agentMapCountLabelWidth(text: string): number { + return textWidth(text, COUNT_FONT_PX) +} + +function boxesOverlap(a: LabelBox, b: LabelBox): boolean { + return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom +} + +function addBox(grid: LabelGrid, box: LabelBox): void { + const left = Math.floor(box.left / DECLUTTER_GRID_PX) + const right = Math.floor(box.right / DECLUTTER_GRID_PX) + const top = Math.floor(box.top / DECLUTTER_GRID_PX) + const bottom = Math.floor(box.bottom / DECLUTTER_GRID_PX) + for (let x = left; x <= right; x += 1) { + let column = grid.get(x) + if (!column) { + column = new Map() + grid.set(x, column) + } + for (let y = top; y <= bottom; y += 1) { + const cell = column.get(y) + if (cell) { + cell.push(box) + } else { + column.set(y, [box]) + } + } + } +} + +function collides(grid: LabelGrid, box: LabelBox): boolean { + const left = Math.floor(box.left / DECLUTTER_GRID_PX) + const right = Math.floor(box.right / DECLUTTER_GRID_PX) + const top = Math.floor(box.top / DECLUTTER_GRID_PX) + const bottom = Math.floor(box.bottom / DECLUTTER_GRID_PX) + for (let x = left; x <= right; x += 1) { + const column = grid.get(x) + if (!column) { + continue + } + for (let y = top; y <= bottom; y += 1) { + for (const placed of column.get(y) ?? []) { + if (boxesOverlap(box, placed)) { + return true + } + } + } + } + return false +} + +/** Projects a centered label from its group's local units into world space + * through the same scale the scene applies to the group. */ +function centeredBox( + centerX: number, + anchorY: number, + scale: number, + width: number, + localTop: number, + localBottom: number +): LabelBox { + const halfWidth = (width / 2 + LABEL_GAP_X_PX) * scale + return { + left: centerX - halfWidth, + right: centerX + halfWidth, + top: anchorY + (localTop - LABEL_GAP_Y_PX) * scale, + bottom: anchorY + (localBottom + LABEL_GAP_Y_PX) * scale + } +} + +/** Box for a centered given its baseline in the group's local units. */ +function baselineBox( + centerX: number, + anchorY: number, + scale: number, + text: string, + fontPx: number, + baseline: number, + widthRatio = GLYPH_WIDTH_RATIO +): LabelBox { + return centeredBox( + centerX, + anchorY, + scale, + textWidth(text, fontPx, widthRatio), + baseline - fontPx * ASCENT_RATIO, + baseline + fontPx * DESCENT_RATIO + ) +} + +/** Attention outranks volume: a blocked workspace keeps its name when a large + * idle neighbour has to drop its own. */ +function labelPriority(worktree: AgentMapWorktreeRing): number { + const attention = worktree.statusCounts.blocked + worktree.statusCounts.waiting + return attention * 1_000_000 + worktree.statusCounts.working * 10_000 + worktree.agents.length +} + +/** Worth drawing before collisions are considered: all-idle workspaces stay + * silent until their ring is big enough on screen to be worth naming. */ +function isLabelCandidate(worktree: AgentMapWorktreeRing, mapScale: number): boolean { + return !worktree.quiet || worktree.radius * mapScale >= 56 +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function addAgentExclusionBoxes(grid: LabelGrid, layout: AgentMapLayout, mapScale: number): void { + const clearance = AGENT_LABEL_CLEARANCE_PX / Math.max(mapScale, 0.001) + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + for (const agent of worktree.agents) { + if (!agent) { + continue + } + const radius = agent.radius + clearance + addBox(grid, { + left: agent.x - radius, + right: agent.x + radius, + top: agent.y - radius, + bottom: agent.y + radius + }) + } + } + } +} + +/** + * Picks the labels that can render without stacking on each other. Labels draw + * at a fixed screen size, so which ones fit depends on zoom but never on pan — + * keeping this pan-independent is what lets the scene stay memoized during a + * drag. Project names claim space first as the map's coarsest landmark, + * workspace names next in priority order, and project counts take what is left. + */ +export function selectVisibleAgentMapLabels( + layout: AgentMapLayout, + labelScale: number, + mapScale: number +): AgentMapVisibleLabels { + const agentGrid: LabelGrid = new Map() + addAgentExclusionBoxes(agentGrid, layout, mapScale) + const grid: LabelGrid = new Map() + addAgentExclusionBoxes(grid, layout, mapScale) + for (const project of layout.projects) { + const name = project.name.toUpperCase() + addBox( + grid, + centeredBox( + project.x, + project.y - project.radius, + labelScale, + PROJECT_LABEL_ICON_PX + textWidth(name, PROJECT_LABEL_FONT_PX, UPPERCASE_GLYPH_WIDTH_RATIO), + PROJECT_NAME_TOP, + PROJECT_NAME_BOTTOM + ) + ) + } + + const candidates: AgentMapWorktreeRing[] = [] + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + if (isLabelCandidate(worktree, mapScale)) { + candidates.push(worktree) + } + } + } + candidates.sort((a, b) => { + const byPriority = labelPriority(b) - labelPriority(a) + if (byPriority !== 0) { + return byPriority + } + const byRadius = b.radius - a.radius + return byRadius !== 0 ? byRadius : compareStable(a.id, b.id) + }) + + const worktreeIds = new Set() + const worktreeAgentSafeIds = new Set() + for (const worktree of candidates.slice(0, MAX_LABEL_CANDIDATES)) { + const box = baselineBox( + worktree.x, + worktree.y - worktree.radius, + labelScale, + worktree.name, + WORKTREE_LABEL_FONT_PX, + WORKTREE_LABEL_BASELINE + ) + if (collides(agentGrid, box)) { + continue + } + worktreeAgentSafeIds.add(worktree.id) + if (collides(grid, box)) { + continue + } + addBox(grid, box) + worktreeIds.add(worktree.id) + } + + const projectCountIds = new Set() + for (const project of layout.projects) { + const count = `${project.agentCount} AGENTS · ${project.worktrees.length} WORKSPACES` + const box = baselineBox( + project.x, + project.y - project.radius, + labelScale, + count, + COUNT_FONT_PX, + COUNT_BASELINE, + UPPERCASE_GLYPH_WIDTH_RATIO + ) + if (collides(grid, box)) { + continue + } + addBox(grid, box) + projectCountIds.add(project.id) + } + + return { worktreeIds, worktreeAgentSafeIds, projectCountIds } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts new file mode 100644 index 00000000000..69238b73cf3 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout-metadata.ts @@ -0,0 +1,53 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import type { AgentMapLayout, AgentMapStatusCounts } from './agent-map-layout' +import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' + +function emptyStatusCounts(): AgentMapStatusCounts { + return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 } +} + +export function refreshAgentMapMetadata( + geometry: AgentMapLayout, + cards: DashboardCard[], + now: number +): AgentMapLayout { + const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card])) + const projects = geometry.projects.map((project) => { + let projectName = project.name + let agentCount = 0 + const worktrees = project.worktrees.map((worktree) => { + let worktreeName = worktree.name + let workspaceKind = worktree.workspaceKind + const statusCounts = emptyStatusCounts() + const agents = worktree.agents.flatMap((agent) => { + const card = cardsByPaneKey.get(agent.card.paneKey) + if (!card) { + return [] + } + projectName = card.repoName + worktreeName = card.worktreeName + workspaceKind = card.workspaceKind ?? 'worktree' + agentCount += 1 + statusCounts[agentMapNodeStatus(card)] += 1 + return [ + { + ...agent, + card, + durationMinutes: agentMapDurationMinutes(card, now), + status: agentMapNodeStatus(card) + } + ] + }) + return { + ...worktree, + name: worktreeName, + workspaceKind, + agents, + statusCounts, + quiet: statusCounts.idle === agents.length + } + }) + return { ...project, name: projectName, worktrees, agentCount } + }) + return { ...geometry, projects } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts new file mode 100644 index 00000000000..52c47be08f5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.test.ts @@ -0,0 +1,604 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { + deriveAgentMapLayout, + AGENT_MAP_AGENT_RADIUS, + AGENT_MAP_RING_HEADER_HEIGHT, + AGENT_MAP_WORKTREE_GAP, + agentMapDurationMinutes, + agentMapNodeStatus, + updateAgentMapLayout +} from './agent-map-layout' +import type * as WorktreePackingModule from './agent-map-worktree-packing' + +const packWorktrees = vi.hoisted(() => vi.fn()) +vi.mock('./agent-map-worktree-packing', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + packAgentMapWorktrees: (...args: Parameters) => { + packWorktrees() + return actual.packAgentMapWorktrees(...args) + } + } +}) + +const NOW = 2_000_000_000 + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + startedAt: NOW - 10 * 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + ...overrides + } +} + +function workspace(overrides: Partial = {}): DashboardWorkspace { + return { + repoId: 'repo-1', + worktreeId: 'empty-worktree', + repoName: 'Orca', + worktreeName: 'Empty worktree', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +describe('agent map layout', () => { + it('lays out agentless workspaces and preserves their workspace lineage', () => { + const layout = deriveAgentMapLayout([], NOW, [ + workspace({ worktreeId: 'parent', worktreeName: 'Parent' }), + workspace({ + worktreeId: 'child', + worktreeName: 'Child', + parentWorktreeId: 'parent' + }) + ]) + const project = layout.projects[0] + const parent = project.worktrees.find((item) => item.worktreeId === 'parent')! + const child = project.worktrees.find((item) => item.worktreeId === 'child')! + + expect(project.agentCount).toBe(0) + expect(parent.agents).toEqual([]) + expect(child.parentId).toBe(parent.id) + expect(child.y).toBeGreaterThan(parent.y) + }) + + it('derives project containment, workspace containment, and every agent node', () => { + const cards = [ + card({ paneKey: 'a', repoId: 'repo-a', worktreeId: 'wt-a' }), + card({ paneKey: 'b', repoId: 'repo-a', worktreeId: 'wt-a' }), + card({ paneKey: 'c', repoId: 'repo-a', worktreeId: 'wt-b' }), + card({ + paneKey: 'd', + repoId: 'repo-b', + repoName: 'Mobile', + worktreeId: 'wt-c' + }) + ] + const layout = deriveAgentMapLayout(cards, NOW) + + expect(layout.projects.map((project) => project.id)).toEqual(['repo-a', 'repo-b']) + expect(layout.projects[0].worktrees.map((worktree) => worktree.worktreeId)).toEqual([ + 'wt-a', + 'wt-b' + ]) + expect( + layout.projects.flatMap((project) => + project.worktrees.flatMap((worktree) => worktree.agents.map((agent) => agent.card.paneKey)) + ) + ).toEqual(['a', 'b', 'c', 'd']) + + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + expect( + Math.hypot(worktree.x - project.x, worktree.y - project.y) + worktree.radius + ).toBeLessThan(project.radius) + for (const agent of worktree.agents) { + expect( + Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius + ).toBeLessThan(worktree.radius) + } + } + } + }) + + it('keeps exact worktree IDs on different hosts in separate rings', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'local', executionHostId: 'local' }), + card({ paneKey: 'remote', executionHostId: 'runtime:env-1' }) + ], + NOW + ) + + expect(layout.projects[0].worktrees).toHaveLength(2) + expect( + layout.projects[0].worktrees.map(({ worktreeId, executionHostId }) => ({ + worktreeId, + executionHostId + })) + ).toEqual( + expect.arrayContaining([ + { worktreeId: 'worktree-1', executionHostId: 'local' }, + { worktreeId: 'worktree-1', executionHostId: 'runtime:env-1' } + ]) + ) + }) + + it('reserves project and workspace header bands above dense ring contents', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 24 }, (_unused, index) => + card({ paneKey: `dense-${index.toString().padStart(2, '0')}` }) + ), + NOW + ) + const project = layout.projects[0] + const worktree = project.worktrees[0] + const projectTop = project.y - project.radius + const worktreeTop = worktree.y - worktree.radius + const agentTop = Math.min(...worktree.agents.map((agent) => agent.y - agent.radius)) + + expect(worktreeTop - projectTop).toBeGreaterThanOrEqual(AGENT_MAP_RING_HEADER_HEIGHT) + expect(agentTop - worktreeTop).toBeGreaterThanOrEqual(AGENT_MAP_RING_HEADER_HEIGHT) + }) + + it('keeps sparse project and workspace rings compact around their header bands', () => { + const single = deriveAgentMapLayout([card()], NOW).projects[0] + const four = deriveAgentMapLayout( + Array.from({ length: 4 }, (_unused, index) => card({ paneKey: `agent-${index}` })), + NOW + ).projects[0] + + expect(single.worktrees[0].radius).toBeLessThanOrEqual(72) + expect(single.radius).toBeLessThanOrEqual(104) + expect(four.worktrees[0].radius).toBeLessThanOrEqual(100) + }) + + it.each([ + ['single', [card()]], + [ + 'sparse', + [ + card({ paneKey: 'a', repoId: 'repo-a' }), + card({ paneKey: 'b', repoId: 'repo-b', worktreeId: 'worktree-2' }) + ] + ] + ])('centers %s project content within the minimum world', (_, cards) => { + const layout = deriveAgentMapLayout(cards, NOW) + const left = Math.min(...layout.projects.map((project) => project.x - project.radius)) + const right = Math.max(...layout.projects.map((project) => project.x + project.radius)) + const top = Math.min(...layout.projects.map((project) => project.y - project.radius)) + const bottom = Math.max(...layout.projects.map((project) => project.y + project.radius)) + + expect(layout.width).toBe(900) + expect(layout.height).toBe(560) + expect((left + right) / 2).toBeCloseTo(layout.width / 2) + expect((top + bottom) / 2).toBeCloseTo(layout.height / 2) + }) + + it('places spawned descendants beneath their direct parent inside the workspace', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent' }), + card({ paneKey: 'child-a', parentPaneKey: 'parent' }), + card({ paneKey: 'child-b', parentPaneKey: 'parent' }), + card({ paneKey: 'grandchild', parentPaneKey: 'child-a' }) + ], + NOW + ) + const worktree = layout.projects[0].worktrees[0] + const agents = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) + const parent = agents.get('parent')! + const childA = agents.get('child-a')! + const childB = agents.get('child-b')! + const grandchild = agents.get('grandchild')! + + expect(childA.y).toBeGreaterThan(parent.y) + expect(childB.y).toBeGreaterThan(parent.y) + expect(grandchild.y).toBeGreaterThan(childA.y) + for (const agent of worktree.agents) { + expect(Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius).toBeLessThan( + worktree.radius + ) + } + }) + + it('packs high-fanout spawned children into a compact deterministic cluster', () => { + const cards = [ + card({ paneKey: 'parent' }), + ...Array.from({ length: 29 }, (_, index) => + card({ + paneKey: `child-${index.toString().padStart(2, '0')}`, + parentPaneKey: 'parent' + }) + ) + ] + const first = deriveAgentMapLayout(cards, NOW).projects[0].worktrees[0] + const second = deriveAgentMapLayout(cards, NOW).projects[0].worktrees[0] + const parent = first.agents.find((agent) => agent.card.paneKey === 'parent')! + const children = first.agents.filter((agent) => agent.card.parentPaneKey === 'parent') + + expect(new Set(children.map((child) => child.y.toFixed(3))).size).toBeGreaterThan(4) + expect(children.every((child) => child.y > parent.y)).toBe(true) + expect( + Math.max(...children.map((child) => child.x)) - Math.min(...children.map((child) => child.x)) + ).toBeLessThan(500) + expect(first.radius).toBeLessThan(350) + for (const [index, child] of children.entries()) { + for (const other of children.slice(index + 1)) { + expect(Math.hypot(child.x - other.x, child.y - other.y)).toBeGreaterThanOrEqual( + AGENT_MAP_AGENT_RADIUS * 2 + ) + } + } + expect(first.agents.map(({ card, x, y }) => ({ paneKey: card.paneKey, x, y }))).toEqual( + second.agents.map(({ card, x, y }) => ({ paneKey: card.paneKey, x, y })) + ) + }) + + it('places visible child worktrees beneath their direct parent', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent', worktreeId: 'parent-worktree' }), + card({ + paneKey: 'child-a', + worktreeId: 'child-a-worktree', + parentWorktreeId: 'parent-worktree' + }), + card({ + paneKey: 'child-b', + worktreeId: 'child-b-worktree', + parentWorktreeId: 'parent-worktree' + }), + card({ + paneKey: 'grandchild', + worktreeId: 'grandchild-worktree', + parentWorktreeId: 'child-a-worktree' + }) + ], + NOW + ) + const worktrees = new Map( + layout.projects[0].worktrees.map((worktree) => [worktree.worktreeId, worktree]) + ) + const parent = worktrees.get('parent-worktree')! + const childA = worktrees.get('child-a-worktree')! + const childB = worktrees.get('child-b-worktree')! + const grandchild = worktrees.get('grandchild-worktree')! + + expect(childA.y).toBeGreaterThan(parent.y) + expect(childB.y).toBeGreaterThan(parent.y) + expect(grandchild.y).toBeGreaterThan(childA.y) + for (const [index, worktree] of layout.projects[0].worktrees.entries()) { + for (const other of layout.projects[0].worktrees.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('clusters cross-worktree spawned agents without inventing workspace lineage', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent', worktreeId: 'parent-worktree' }), + card({ + paneKey: 'child', + worktreeId: 'child-worktree', + parentPaneKey: 'parent' + }), + card({ paneKey: 'unrelated', worktreeId: 'unrelated-worktree' }) + ], + NOW + ) + const worktrees = new Map( + layout.projects[0].worktrees.map((worktree) => [worktree.worktreeId, worktree]) + ) + const parent = worktrees.get('parent-worktree')! + const child = worktrees.get('child-worktree')! + + expect(child.clusterParentId).toBe(parent.id) + expect(child.parentId).toBeUndefined() + expect(child.y).toBeGreaterThan(parent.y) + expect(Math.hypot(child.x - parent.x, child.y - parent.y)).toBeLessThan( + child.radius + parent.radius + 100 + ) + }) + + it('clusters spawned agents whose parent is in another project', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent', repoId: 'repo-parent', worktreeId: 'parent-worktree' }), + card({ + paneKey: 'child', + repoId: 'repo-child', + worktreeId: 'child-worktree', + parentPaneKey: 'parent' + }), + card({ paneKey: 'unrelated', repoId: 'repo-unrelated', worktreeId: 'unrelated-worktree' }) + ], + NOW + ) + const projects = new Map(layout.projects.map((project) => [project.id, project])) + const parent = projects.get('repo-parent')! + const child = projects.get('repo-child')! + + expect(child.y).toBeGreaterThan(parent.y) + expect(Math.hypot(child.x - parent.x, child.y - parent.y)).toBeLessThan( + child.radius + parent.radius + 100 + ) + }) + + it('repacks cached geometry when a worktree parent changes', () => { + const cards = [ + card({ paneKey: 'parent-a', worktreeId: 'parent-a' }), + card({ paneKey: 'parent-b', worktreeId: 'parent-b' }), + card({ paneKey: 'child', worktreeId: 'child', parentWorktreeId: 'parent-a' }) + ] + const initial = updateAgentMapLayout(null, cards, NOW) + const updated = updateAgentMapLayout( + initial.cache, + cards.map((candidate) => + candidate.worktreeId === 'child' + ? { ...candidate, parentWorktreeId: 'parent-b' } + : candidate + ), + NOW + ) + + expect(updated.cache).not.toBe(initial.cache) + expect(updated.cache.packingGeneration).toBe(2) + }) + + it('repacks cached geometry when a spawn parent changes', () => { + const cards = [ + card({ paneKey: 'parent-a' }), + card({ paneKey: 'parent-b' }), + card({ paneKey: 'child', parentPaneKey: 'parent-a' }) + ] + const initial = updateAgentMapLayout(null, cards, NOW) + const updated = updateAgentMapLayout( + initial.cache, + cards.map((candidate) => + candidate.paneKey === 'child' ? { ...candidate, parentPaneKey: 'parent-b' } : candidate + ), + NOW + ) + + expect(updated.cache).not.toBe(initial.cache) + expect(updated.cache.packingGeneration).toBe(2) + expect(updated.layout.topologyKey).not.toBe(initial.layout.topologyKey) + }) + + it('keeps positions stable across routine status and duration updates', () => { + const initialCards = [ + card({ paneKey: 'a', worktreeId: 'wt-a' }), + card({ paneKey: 'b', worktreeId: 'wt-a', startedAt: NOW - 2 * 60_000 }), + card({ paneKey: 'c', worktreeId: 'wt-b' }) + ] + const initial = deriveAgentMapLayout(initialCards, NOW) + const updated = deriveAgentMapLayout( + [ + { ...initialCards[0], bucket: 'attention', dotState: 'waiting' }, + { ...initialCards[1], startedAt: NOW - 45 * 60_000 }, + initialCards[2] + ], + NOW + ) + const initialAgents = initial.projects[0].worktrees[0].agents + const updatedAgents = updated.projects[0].worktrees[0].agents + const initialWorktrees = initial.projects[0].worktrees + const updatedWorktrees = updated.projects[0].worktrees + + expect(updated.topologyKey).toBe(initial.topologyKey) + expect(updatedWorktrees.map(({ x, y }) => ({ x, y }))).toEqual( + initialWorktrees.map(({ x, y }) => ({ x, y })) + ) + expect(updatedAgents.map(({ x, y }) => ({ x, y }))).toEqual( + initialAgents.map(({ x, y }) => ({ x, y })) + ) + expect(updatedAgents[1].radius).toBe(initialAgents[1].radius) + }) + + it('reuses packed geometry while refreshing live card metadata', () => { + const initialCards = [ + card({ paneKey: 'a', worktreeId: 'wt-a' }), + card({ paneKey: 'b', worktreeId: 'wt-b' }) + ] + const initial = updateAgentMapLayout(null, initialCards, NOW) + packWorktrees.mockClear() + const updatedCards = [ + { ...initialCards[0], dotState: 'waiting' as const, worktreeName: 'Renamed' }, + { ...initialCards[1], startedAt: NOW - 60 * 60_000 } + ] + const updated = updateAgentMapLayout(initial.cache, updatedCards, NOW + 60_000) + + expect(updated.cache).toBe(initial.cache) + expect(packWorktrees).not.toHaveBeenCalled() + expect(initial.cache.geometry).toBe(initial.layout) + expect(updated.cache.packingGeneration).toBe(1) + expect(updated.layout.projects[0].worktrees[0].name).toBe('Renamed') + expect(updated.layout.projects[0].worktrees[0].statusCounts.waiting).toBe(1) + expect(updated.layout.projects[0].worktrees[1].agents[0].durationMinutes).toBe(61) + + const topologyChanged = updateAgentMapLayout( + updated.cache, + [...updatedCards, card({ paneKey: 'c', worktreeId: 'wt-c' })], + NOW + ) + expect(topologyChanged.cache).not.toBe(updated.cache) + expect(packWorktrees).toHaveBeenCalled() + expect(topologyChanged.cache.packingGeneration).toBe(2) + }) + + it('packs worktree rings tightly without a square grid', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 36 }, (_, index) => + card({ + paneKey: `agent-${index}`, + worktreeId: `worktree-${index.toString().padStart(2, '0')}` + }) + ), + NOW + ) + const project = layout.projects[0] + + expect(project.radius).toBeLessThan(700) + expect( + new Set(project.worktrees.map((worktree) => worktree.x.toFixed(3))).size + ).toBeGreaterThan(12) + expect( + new Set(project.worktrees.map((worktree) => worktree.y.toFixed(3))).size + ).toBeGreaterThan(12) + for (const [index, worktree] of project.worktrees.entries()) { + for (const other of project.worktrees.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('uses one agent size while retaining elapsed duration', () => { + const finished = card({ + startedAt: NOW - 30 * 60_000, + finishedAt: NOW - 20 * 60_000 + }) + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'just-started', startedAt: NOW }), + card({ paneKey: 'long-running', startedAt: NOW - 24 * 60 * 60_000 }) + ], + NOW + ) + + expect(layout.projects[0].worktrees[0].agents.map((agent) => agent.radius)).toEqual([ + AGENT_MAP_AGENT_RADIUS, + AGENT_MAP_AGENT_RADIUS + ]) + expect(agentMapDurationMinutes(finished, NOW)).toBe(10) + }) + + it('maps acknowledged completions to idle independently from elapsed time', () => { + for (const dotState of ['working', 'blocked', 'waiting', 'idle'] as const) { + expect(agentMapNodeStatus(card({ dotState }))).toBe(dotState) + } + expect(agentMapNodeStatus(card({ dotState: 'done', unseen: true }))).toBe('done') + expect(agentMapNodeStatus(card({ dotState: 'done', unseen: false }))).toBe('idle') + const shortBlocked = card({ dotState: 'blocked', startedAt: NOW - 60_000 }) + const longBlocked = card({ dotState: 'blocked', startedAt: NOW - 45 * 60_000 }) + expect(agentMapNodeStatus(shortBlocked)).toBe(agentMapNodeStatus(longBlocked)) + }) + + it('marks only operationally quiet workspaces for semantic aggregation', () => { + const quiet = deriveAgentMapLayout( + Array.from({ length: 5 }, (_, index) => + card({ paneKey: `quiet-${index}`, dotState: index === 0 ? 'done' : 'idle' }) + ), + NOW + ) + const active = deriveAgentMapLayout([card({ paneKey: 'active', dotState: 'working' })], NOW) + const unseenDone = deriveAgentMapLayout( + Array.from({ length: 5 }, (_, index) => + card({ paneKey: `done-${index}`, dotState: 'done', unseen: true }) + ), + NOW + ) + + expect(quiet.projects[0].worktrees[0].quiet).toBe(true) + expect(active.projects[0].worktrees[0].quiet).toBe(false) + expect(unseenDone.projects[0].worktrees[0].quiet).toBe(false) + }) + + it('places hundreds of agents in one workspace without overlap', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 400 }, (_, index) => card({ paneKey: `agent-${index}` })), + NOW + ) + const worktree = layout.projects[0].worktrees[0] + + expect(worktree.agents).toHaveLength(400) + let minimumDistance = Number.POSITIVE_INFINITY + for (const [index, agent] of worktree.agents.entries()) { + expect(Math.hypot(agent.x - worktree.x, agent.y - worktree.y) + agent.radius).toBeLessThan( + worktree.radius + ) + for (const other of worktree.agents.slice(index + 1)) { + minimumDistance = Math.min( + minimumDistance, + Math.hypot(agent.x - other.x, agent.y - other.y) + ) + } + } + expect(minimumDistance).toBeGreaterThanOrEqual(AGENT_MAP_AGENT_RADIUS * 2) + }) + + it('keeps deeply nested spawn lineage finite without recursive stack growth', () => { + const layout = deriveAgentMapLayout( + Array.from({ length: 5_000 }, (_, index) => + card({ + paneKey: `agent-${index.toString().padStart(4, '0')}`, + parentPaneKey: + index === 0 ? undefined : `agent-${(index - 1).toString().padStart(4, '0')}` + }) + ), + NOW + ) + const worktree = layout.projects[0].worktrees[0] + const agents = new Map(worktree.agents.map((agent) => [agent.card.paneKey, agent])) + + expect(worktree.agents).toHaveLength(5_000) + expect(Number.isFinite(worktree.radius)).toBe(true) + expect(worktree.radius).toBeLessThan(1_000_000) + for (const agent of worktree.agents) { + if (agent.card.parentPaneKey) { + expect(agent.y).toBeGreaterThan(agents.get(agent.card.parentPaneKey)!.y) + } + } + }) + + it('wraps very large spawn fanout without overlap', () => { + const layout = deriveAgentMapLayout( + [ + card({ paneKey: 'parent' }), + ...Array.from({ length: 300 }, (_, index) => + card({ paneKey: `child-${index}`, parentPaneKey: 'parent' }) + ) + ], + NOW + ) + const worktree = layout.projects[0].worktrees[0] + const parent = worktree.agents.find((agent) => agent.card.paneKey === 'parent')! + const children = worktree.agents.filter((agent) => agent.card.parentPaneKey === 'parent') + let minimumDistance = Number.POSITIVE_INFINITY + + expect(children.every((child) => child.y > parent.y)).toBe(true) + expect(worktree.radius).toBeLessThan(1_000) + for (const [index, child] of children.entries()) { + for (const other of children.slice(index + 1)) { + minimumDistance = Math.min( + minimumDistance, + Math.hypot(child.x - other.x, child.y - other.y) + ) + } + } + expect(minimumDistance).toBeGreaterThanOrEqual(AGENT_MAP_AGENT_RADIUS * 2) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts new file mode 100644 index 00000000000..68602f95c04 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-layout.ts @@ -0,0 +1,308 @@ +import type * as DashboardSnapshotTypes from '../../../../shared/dashboard-snapshot' +import { placeAgentMapAgents } from './agent-map-agent-placement' +import { layoutAgentMapLineage } from './agent-map-lineage-layout' +import { refreshAgentMapMetadata } from './agent-map-layout-metadata' +import { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' +import { placeAgentMapProjects } from './agent-map-project-placement' +import { selectAgentMapSpawnParentContainer } from './agent-map-spawn-clustering' +import { + agentMapCardTopologyIdentity, + agentMapWorkspaceIdentity, + agentMapWorkspaceTopologyIdentity, + agentMapWorktreeIdentity, + agentMapWorktreeIdentityFromParts +} from './agent-map-workspace-identity' +import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout' + +type DashboardCard = DashboardSnapshotTypes.DashboardCard +type DashboardCardDotState = DashboardSnapshotTypes.DashboardCardDotState +type DashboardWorkspace = DashboardSnapshotTypes.DashboardWorkspace + +export { AGENT_MAP_WORKTREE_GAP } from './agent-map-worktree-packing' +export { agentMapDurationMinutes, agentMapNodeStatus } from './agent-map-node-metadata' + +export const AGENT_MAP_AGENT_RADIUS = 20 +export const AGENT_MAP_AGGREGATE_ZOOM = 1.15 +export const AGENT_MAP_RING_HEADER_HEIGHT = 40 + +const PROJECT_PADDING = 12 +const WORLD_MARGIN = 32 +const RING_CONTENT_OFFSET = AGENT_MAP_RING_HEADER_HEIGHT / 2 + +export type AgentMapStatusCounts = Record + +export type AgentMapAgentNode = { + card: DashboardCard + x: number + y: number + radius: number + durationMinutes: number + status: DashboardCardDotState +} + +export type AgentMapWorktreeRing = { + id: string + parentId?: string + /** Layout-only parent chosen from agent spawn edges; does not imply workspace lineage. */ + clusterParentId?: string + worktreeId: string + executionHostId: DashboardCard['executionHostId'] + name: string + workspaceKind: NonNullable + x: number + y: number + radius: number + agents: AgentMapAgentNode[] + statusCounts: AgentMapStatusCounts + quiet: boolean +} + +export type AgentMapProjectRing = { + id: string + name: string + x: number + y: number + radius: number + worktrees: AgentMapWorktreeRing[] + agentCount: number +} + +export type AgentMapLayout = { + projects: AgentMapProjectRing[] + width: number + height: number + topologyKey: string +} + +export type AgentMapLayoutCache = { + topologyKey: string + geometry: AgentMapLayout + packingGeneration: number +} + +type LocalWorktree = Omit & { x: number; y: number } +type LocalProject = Omit & { + x: number + y: number + clusterParentId?: string + worktrees: LocalWorktree[] +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +export function agentMapTopologyKey( + cards: DashboardCard[], + workspaces: DashboardWorkspace[] = [] +): string { + return [ + ...cards.map((card) => `a:${agentMapCardTopologyIdentity(card)}`), + ...workspaces.map((workspace) => `w:${agentMapWorkspaceTopologyIdentity(workspace)}`) + ] + .sort(compareStable) + .join('|') +} + +export function shouldAggregateAgentMapWorktree( + worktree: AgentMapWorktreeRing, + zoom: number, + allowAggregation = true +): boolean { + return ( + allowAggregation && + zoom < AGENT_MAP_AGGREGATE_ZOOM && + worktree.quiet && + worktree.agents.length > 3 + ) +} + +function emptyStatusCounts(): AgentMapStatusCounts { + return { working: 0, blocked: 0, waiting: 0, done: 0, idle: 0 } +} + +function worktreeRadius(agentCount: number): number { + return Math.max( + 52, + 24 + Math.ceil(Math.sqrt(Math.max(1, agentCount))) * (AGENT_MAP_AGENT_RADIUS + 8) + ) +} + +function buildLocalWorktree( + id: string, + cards: DashboardCard[], + now: number, + workspace?: DashboardWorkspace +): LocalWorktree { + const lineageLayout = layoutAgentMapLineage(cards, AGENT_MAP_AGENT_RADIUS) + const contentRadius = lineageLayout?.radius ?? worktreeRadius(cards.length) + const radius = contentRadius + RING_CONTENT_OFFSET + const statusCounts = emptyStatusCounts() + for (const card of cards) { + statusCounts[agentMapNodeStatus(card)] += 1 + } + const executionHostId = workspace?.executionHostId ?? cards[0]?.executionHostId + const parentWorktreeId = workspace?.parentWorktreeId ?? cards[0]?.parentWorktreeId + return { + id, + parentId: parentWorktreeId + ? agentMapWorktreeIdentityFromParts(parentWorktreeId, executionHostId) + : undefined, + worktreeId: workspace?.worktreeId ?? cards[0]?.worktreeId ?? id, + executionHostId, + name: workspace?.worktreeName ?? cards[0]?.worktreeName ?? id, + workspaceKind: workspace?.workspaceKind ?? cards[0]?.workspaceKind ?? 'worktree', + x: 0, + y: 0, + radius, + agents: ( + lineageLayout?.agents.map(({ card, x, y }) => ({ + card, + x, + y, + radius: AGENT_MAP_AGENT_RADIUS, + durationMinutes: agentMapDurationMinutes(card, now), + status: agentMapNodeStatus(card) + })) ?? + placeAgentMapAgents({ + worktreeId: id, + cards, + radius: contentRadius, + agentRadius: AGENT_MAP_AGENT_RADIUS, + now + }) + ).map((agent) => ({ ...agent, y: agent.y + RING_CONTENT_OFFSET })), + statusCounts, + quiet: statusCounts.idle === cards.length + } +} + +function buildLocalProject( + id: string, + cards: DashboardCard[], + workspaces: DashboardWorkspace[], + cardsByPaneKey: ReadonlyMap, + now: number +): LocalProject { + const byWorktree = new Map() + for (const card of cards) { + const identity = agentMapWorktreeIdentity(card) + const current = byWorktree.get(identity) + if (current) { + current.push(card) + } else { + byWorktree.set(identity, [card]) + } + } + const workspacesById = new Map( + workspaces.map((workspace) => [agentMapWorkspaceIdentity(workspace), workspace]) + ) + for (const workspaceId of workspacesById.keys()) { + if (!byWorktree.has(workspaceId)) { + byWorktree.set(workspaceId, []) + } + } + const positionedWorktrees = layoutAgentMapWorktreeLineage( + [...byWorktree.entries()] + .sort(([a], [b]) => compareStable(a, b)) + .map(([worktreeId, worktreeCards]) => ({ + ...buildLocalWorktree(worktreeId, worktreeCards, now, workspacesById.get(worktreeId)), + clusterParentId: selectAgentMapSpawnParentContainer( + worktreeCards, + cardsByPaneKey, + agentMapWorktreeIdentity + ) + })) + ) + const contentRadius = Math.max( + 84, + ...positionedWorktrees.map( + (worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius + PROJECT_PADDING + ) + ) + const worktrees = positionedWorktrees.map((worktree) => ({ + ...worktree, + y: worktree.y + RING_CONTENT_OFFSET + })) + return { + id, + name: cards[0]?.repoName ?? workspaces[0]?.repoName ?? id, + x: 0, + y: 0, + clusterParentId: selectAgentMapSpawnParentContainer( + cards, + cardsByPaneKey, + (card) => card.repoId + ), + radius: contentRadius + RING_CONTENT_OFFSET, + worktrees, + agentCount: cards.length + } +} + +export function deriveAgentMapLayout( + cards: DashboardCard[], + now: number, + workspaces: DashboardWorkspace[] = [] +): AgentMapLayout { + const topologyKey = agentMapTopologyKey(cards, workspaces) + if (cards.length === 0 && workspaces.length === 0) { + return { projects: [], width: 900, height: 560, topologyKey } + } + const byProject = new Map() + for (const card of cards) { + const current = byProject.get(card.repoId) ?? { cards: [], workspaces: [] } + current.cards.push(card) + byProject.set(card.repoId, current) + } + for (const workspace of workspaces) { + const current = byProject.get(workspace.repoId) ?? { cards: [], workspaces: [] } + current.workspaces.push(workspace) + byProject.set(workspace.repoId, current) + } + const cardsByPaneKey = new Map(cards.map((card) => [card.paneKey, card])) + const localProjects = [...byProject.entries()] + .sort(([a], [b]) => compareStable(a, b)) + .map(([projectId, project]) => + buildLocalProject(projectId, project.cards, project.workspaces, cardsByPaneKey, now) + ) + const framed = placeAgentMapProjects(localProjects, 900, 560, WORLD_MARGIN) + const projects = framed.projects.map((project): AgentMapProjectRing => { + return { + ...project, + worktrees: project.worktrees.map((worktree) => ({ + ...worktree, + x: project.x + worktree.x, + y: project.y + worktree.y, + agents: worktree.agents.map((agent) => ({ + ...agent, + x: project.x + worktree.x + agent.x, + y: project.y + worktree.y + agent.y + })) + })) + } + }) + return { projects, width: framed.width, height: framed.height, topologyKey } +} + +export function updateAgentMapLayout( + cache: AgentMapLayoutCache | null, + cards: DashboardCard[], + now: number, + workspaces: DashboardWorkspace[] = [] +): { cache: AgentMapLayoutCache; layout: AgentMapLayout } { + const topologyKey = agentMapTopologyKey(cards, workspaces) + if (!cache || cache.topologyKey !== topologyKey) { + const geometry = deriveAgentMapLayout(cards, now, workspaces) + return { + cache: { + topologyKey, + geometry, + packingGeneration: (cache?.packingGeneration ?? 0) + 1 + }, + layout: geometry + } + } + const layout = refreshAgentMapMetadata(cache.geometry, cards, now) + return { cache, layout } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts new file mode 100644 index 00000000000..f5d3665c483 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-layout.ts @@ -0,0 +1,224 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { packAgentMapWorktrees } from './agent-map-worktree-packing' + +const HORIZONTAL_GAP = 54 +const VERTICAL_GAP = 58 +const FAMILY_PADDING = 8 +const WORKTREE_PADDING = 6 +const COMPACT_FANOUT_THRESHOLD = 12 +const MAX_EXACT_LINEAGE_AGENTS = 256 + +export type AgentMapLineagePosition = { + card: DashboardCard + x: number + y: number +} + +type AgentMapAgentFamily = { + id: string + x: number + y: number + radius: number + agents: AgentMapLineagePosition[] +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function encloseFamily( + id: string, + agents: AgentMapLineagePosition[], + nodeRadius: number +): AgentMapAgentFamily { + let left = Number.POSITIVE_INFINITY + let right = Number.NEGATIVE_INFINITY + let top = Number.POSITIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + for (const agent of agents) { + left = Math.min(left, agent.x - nodeRadius) + right = Math.max(right, agent.x + nodeRadius) + top = Math.min(top, agent.y - nodeRadius) + bottom = Math.max(bottom, agent.y + nodeRadius) + } + const centerX = (left + right) / 2 + const centerY = (top + bottom) / 2 + let radius = 0 + for (const agent of agents) { + agent.x -= centerX + agent.y -= centerY + radius = Math.max(radius, Math.hypot(agent.x, agent.y) + nodeRadius + FAMILY_PADDING) + } + return { id, x: 0, y: 0, radius, agents } +} + +function buildCompactFanoutFamily( + root: DashboardCard, + children: DashboardCard[], + nodeRadius: number, + emitted: Set +): AgentMapAgentFamily { + const columns = Math.ceil(Math.sqrt(children.length)) + const width = (Math.min(columns, children.length) - 1) * HORIZONTAL_GAP + const agents: AgentMapLineagePosition[] = [{ card: root, x: 0, y: 0 }] + emitted.add(root.paneKey) + for (const [index, child] of children.entries()) { + emitted.add(child.paneKey) + agents.push({ + card: child, + x: (index % columns) * HORIZONTAL_GAP - width / 2, + y: (Math.floor(index / columns) + 1) * VERTICAL_GAP + }) + } + return encloseFamily(root.paneKey, agents, nodeRadius) +} + +function buildFamily( + root: DashboardCard, + childrenByParent: ReadonlyMap, + nodeRadius: number, + emitted: Set +): AgentMapAgentFamily { + const agents: AgentMapLineagePosition[] = [] + let leafIndex = 0 + const rootChildren = (childrenByParent.get(root.paneKey) ?? []).filter( + (child) => !emitted.has(child.paneKey) + ) + if ( + rootChildren.length >= COMPACT_FANOUT_THRESHOLD && + rootChildren.every((child) => (childrenByParent.get(child.paneKey) ?? []).length === 0) + ) { + return buildCompactFanoutFamily(root, rootChildren, nodeRadius, emitted) + } + + const placeSubtree = ( + card: DashboardCard, + depth: number, + ancestors: ReadonlySet + ): number => { + if (ancestors.has(card.paneKey) || emitted.has(card.paneKey)) { + return leafIndex++ * HORIZONTAL_GAP + } + emitted.add(card.paneKey) + const nextAncestors = new Set(ancestors) + nextAncestors.add(card.paneKey) + const children = (childrenByParent.get(card.paneKey) ?? []).filter( + (child) => !nextAncestors.has(child.paneKey) && !emitted.has(child.paneKey) + ) + const childXs = children.map((child) => placeSubtree(child, depth + 1, nextAncestors)) + const x = + childXs.length > 0 + ? (Math.min(...childXs) + Math.max(...childXs)) / 2 + : leafIndex++ * HORIZONTAL_GAP + agents.push({ card, x, y: depth * VERTICAL_GAP }) + return x + } + + placeSubtree(root, 0, new Set()) + return encloseFamily(root.paneKey, agents, nodeRadius) +} + +function layoutBoundedLineage( + sorted: DashboardCard[], + childrenByParent: ReadonlyMap, + childPaneKeys: ReadonlySet, + nodeRadius: number +): { agents: AgentMapLineagePosition[]; radius: number } { + const levels: DashboardCard[][] = [] + const emitted = new Set() + const roots = sorted.filter((card) => !childPaneKeys.has(card.paneKey)) + for (const seed of [...roots, ...sorted]) { + if (emitted.has(seed.paneKey)) { + continue + } + const stack = [{ card: seed, depth: 0 }] + while (stack.length > 0) { + const entry = stack.pop()! + if (emitted.has(entry.card.paneKey)) { + continue + } + emitted.add(entry.card.paneKey) + const level = levels[entry.depth] ?? [] + levels[entry.depth] = level + level.push(entry.card) + const children = childrenByParent.get(entry.card.paneKey) ?? [] + for (let index = children.length - 1; index >= 0; index -= 1) { + if (!emitted.has(children[index].paneKey)) { + stack.push({ card: children[index], depth: entry.depth + 1 }) + } + } + } + } + + const agents: AgentMapLineagePosition[] = [] + let rowIndex = 0 + for (const level of levels) { + const columns = Math.ceil(Math.sqrt(level.length)) + for (let rowStart = 0; rowStart < level.length; rowStart += columns) { + const row = level.slice(rowStart, rowStart + columns) + const width = (row.length - 1) * HORIZONTAL_GAP + for (const [index, card] of row.entries()) { + agents.push({ card, x: index * HORIZONTAL_GAP - width / 2, y: rowIndex * VERTICAL_GAP }) + } + rowIndex += 1 + } + } + const family = encloseFamily(sorted[0].paneKey, agents, nodeRadius) + family.agents.sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey)) + return { agents: family.agents, radius: Math.max(52, family.radius + WORKTREE_PADDING) } +} + +export function layoutAgentMapLineage( + cards: DashboardCard[], + nodeRadius: number +): { agents: AgentMapLineagePosition[]; radius: number } | null { + const sorted = [...cards].sort((a, b) => compareStable(a.paneKey, b.paneKey)) + const cardsByPaneKey = new Map(sorted.map((card) => [card.paneKey, card])) + const childrenByParent = new Map() + const childPaneKeys = new Set() + + for (const card of sorted) { + const parentPaneKey = card.parentPaneKey + if (!parentPaneKey || parentPaneKey === card.paneKey || !cardsByPaneKey.has(parentPaneKey)) { + continue + } + childPaneKeys.add(card.paneKey) + childrenByParent.set(parentPaneKey, [...(childrenByParent.get(parentPaneKey) ?? []), card]) + } + if (childPaneKeys.size === 0) { + return null + } + if (sorted.length > MAX_EXACT_LINEAGE_AGENTS) { + return layoutBoundedLineage(sorted, childrenByParent, childPaneKeys, nodeRadius) + } + + const emitted = new Set() + const roots = sorted.filter((card) => !childPaneKeys.has(card.paneKey)) + const families: AgentMapAgentFamily[] = [] + for (const root of roots) { + if (!emitted.has(root.paneKey)) { + families.push(buildFamily(root, childrenByParent, nodeRadius, emitted)) + } + } + for (const card of sorted) { + if (!emitted.has(card.paneKey)) { + families.push(buildFamily(card, childrenByParent, nodeRadius, emitted)) + } + } + const packed = packAgentMapWorktrees(families) + return { + agents: packed + .flatMap((family) => + family.agents.map((agent) => ({ + ...agent, + x: family.x + agent.x, + y: family.y + agent.y + })) + ) + .sort((a, b) => compareStable(a.card.paneKey, b.card.paneKey)), + radius: Math.max( + 52, + ...packed.map((family) => Math.hypot(family.x, family.y) + family.radius + WORKTREE_PADDING) + ) + } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts new file mode 100644 index 00000000000..d8c4baf6edc --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-navigation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' +import { deriveAgentMapLayout } from './agent-map-layout' +import { navigableAgentMapAgents } from './agent-map-navigation' + +const NOW = 2_000_000_000 + +function card(paneKey: string, worktreeId: string, idle: boolean): DashboardCard { + return { + paneKey, + ptyId: `pty-${paneKey}`, + agentType: 'codex', + bucket: idle ? 'idle' : 'working', + dotState: idle ? 'idle' : 'working', + task: '', + repoId: 'repo-1', + worktreeId, + tabId: `tab-${paneKey}`, + leafId: `leaf-${paneKey}`, + repoName: 'Orca', + worktreeName: worktreeId, + startedAt: NOW - 60_000, + finishedAt: idle ? NOW - 30_000 : null, + stateChangedAt: NOW - 30_000, + unseen: false + } +} + +describe('agent map keyboard navigation visibility', () => { + it('excludes every aggregated worktree and restores a selected quiet worktree', () => { + const quietCards = Array.from({ length: 5 }, (_, index) => + card(`quiet-${index}`, 'quiet-worktree', true) + ) + const active = card('active', 'active-worktree', false) + const layout = deriveAgentMapLayout([...quietCards, active], NOW) + + expect( + navigableAgentMapAgents(layout, 1, true, null).map((agent) => agent.card.paneKey) + ).toEqual(['active']) + expect(navigableAgentMapAgents(layout, 1, true, 'quiet-0')).toHaveLength(6) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts b/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts new file mode 100644 index 00000000000..ae3cfdbea2b --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-navigation.ts @@ -0,0 +1,53 @@ +import type { AgentMapAgentNode, AgentMapLayout } from './agent-map-layout' +import { shouldAggregateAgentMapWorktree } from './agent-map-layout' + +type Direction = { x: number; y: number } + +export function agentMapAgents(layout: AgentMapLayout): AgentMapAgentNode[] { + return layout.projects.flatMap((project) => + project.worktrees.flatMap((worktree) => worktree.agents) + ) +} + +export function navigableAgentMapAgents( + layout: AgentMapLayout, + zoom: number, + allowAggregation: boolean, + selectedPaneKey: string | null +): AgentMapAgentNode[] { + return layout.projects.flatMap((project) => + project.worktrees.flatMap((worktree) => { + const containsSelection = worktree.agents.some( + (agent) => agent.card.paneKey === selectedPaneKey + ) + return !containsSelection && shouldAggregateAgentMapWorktree(worktree, zoom, allowAggregation) + ? [] + : worktree.agents + }) + ) +} + +export function nextDirectionalAgent( + current: AgentMapAgentNode, + agents: AgentMapAgentNode[], + direction: Direction +): AgentMapAgentNode | null { + let best: { agent: AgentMapAgentNode; score: number } | null = null + for (const candidate of agents) { + if (candidate.card.paneKey === current.card.paneKey) { + continue + } + const dx = candidate.x - current.x + const dy = candidate.y - current.y + const forward = dx * direction.x + dy * direction.y + if (forward <= 0) { + continue + } + const sideways = Math.abs(dx * direction.y - dy * direction.x) + const score = forward + sideways * 2 + if (!best || score < best.score) { + best = { agent: candidate, score } + } + } + return best?.agent ?? null +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts new file mode 100644 index 00000000000..09532614d6d --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-node-metadata.ts @@ -0,0 +1,17 @@ +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardCardDotState +} from '../../../../shared/dashboard-snapshot' + +export function agentMapDurationMinutes(card: DashboardCard, now: number): number { + if (!Number.isFinite(card.startedAt) || card.startedAt <= 0) { + return 0 + } + const end = card.finishedAt && card.finishedAt >= card.startedAt ? card.finishedAt : now + return Math.max(0, (end - card.startedAt) / 60_000) +} + +export function agentMapNodeStatus(card: DashboardCard): DashboardCardDotState { + return dashboardCardDisplayState(card) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts b/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts new file mode 100644 index 00000000000..83aefde2f06 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-packing-spatial-index.ts @@ -0,0 +1,94 @@ +export const AGENT_MAP_WORKTREE_GAP = 8 +export const AGENT_MAP_PACKING_SCORE_TOLERANCE = 0.001 + +const PACKING_GRID_SIZE = 128 + +export type AgentMapPackableCircle = { + id: string + x: number + y: number + radius: number +} + +type PackingSpatialGrid = { + cells: Map> + cellSize: number +} + +export type AgentMapPackingSpatialIndex = Map + +function packingGridLevel(radius: number): number { + return Math.max( + 0, + Math.ceil(Math.log2((radius * 2 + AGENT_MAP_WORKTREE_GAP) / PACKING_GRID_SIZE)) + ) +} + +export function addAgentMapPackingCircle( + index: AgentMapPackingSpatialIndex, + circle: AgentMapPackableCircle +): void { + const level = packingGridLevel(circle.radius) + let grid = index.get(level) + if (!grid) { + grid = { cells: new Map(), cellSize: PACKING_GRID_SIZE * 2 ** level } + index.set(level, grid) + } + const left = Math.floor((circle.x - circle.radius) / grid.cellSize) + const right = Math.floor((circle.x + circle.radius) / grid.cellSize) + const top = Math.floor((circle.y - circle.radius) / grid.cellSize) + const bottom = Math.floor((circle.y + circle.radius) / grid.cellSize) + for (let x = left; x <= right; x += 1) { + let column = grid.cells.get(x) + if (!column) { + column = new Map() + grid.cells.set(x, column) + } + for (let y = top; y <= bottom; y += 1) { + const cell = column.get(y) + if (cell) { + cell.push(circle) + } else { + column.set(y, [circle]) + } + } + } +} + +export function agentMapPackingCircleOverlaps( + candidate: Pick, + index: AgentMapPackingSpatialIndex +): boolean { + const searchRadius = candidate.radius + AGENT_MAP_WORKTREE_GAP + const checked = new Set() + for (const grid of index.values()) { + const left = Math.floor((candidate.x - searchRadius) / grid.cellSize) + const right = Math.floor((candidate.x + searchRadius) / grid.cellSize) + const top = Math.floor((candidate.y - searchRadius) / grid.cellSize) + const bottom = Math.floor((candidate.y + searchRadius) / grid.cellSize) + for (let x = left; x <= right; x += 1) { + const column = grid.cells.get(x) + if (!column) { + continue + } + for (let y = top; y <= bottom; y += 1) { + for (const circle of column.get(y) ?? []) { + if (checked.has(circle)) { + continue + } + checked.add(circle) + if ( + Math.hypot(candidate.x - circle.x, candidate.y - circle.y) < + candidate.radius + + circle.radius + + AGENT_MAP_WORKTREE_GAP - + AGENT_MAP_PACKING_SCORE_TOLERANCE + ) { + return true + } + } + } + } + } + return false +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts b/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts new file mode 100644 index 00000000000..413102bb3f0 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-project-placement.ts @@ -0,0 +1,50 @@ +import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout' + +const PROJECT_GAP = 32 + +type ProjectCircle = { + id: string + x: number + y: number + radius: number + clusterParentId?: string +} + +function placeUnlinkedProjects(projects: T[]): T[] { + let cursorX = 0 + return projects.map((project) => { + const positioned = { ...project, x: cursorX + project.radius, y: 0 } + cursorX += project.radius * 2 + PROJECT_GAP + return positioned + }) +} + +export function placeAgentMapProjects( + projects: T[], + minimumWidth: number, + minimumHeight: number, + worldMargin: number +): { projects: T[]; width: number; height: number } { + const positioned = projects.some((project) => project.clusterParentId) + ? layoutAgentMapWorktreeLineage(projects) + : placeUnlinkedProjects(projects) + const left = Math.min(...positioned.map((project) => project.x - project.radius)) + const right = Math.max(...positioned.map((project) => project.x + project.radius)) + const top = Math.min(...positioned.map((project) => project.y - project.radius)) + const bottom = Math.max(...positioned.map((project) => project.y + project.radius)) + const naturalWidth = right - left + worldMargin * 2 + const naturalHeight = bottom - top + worldMargin * 2 + const width = Math.max(minimumWidth, naturalWidth) + const height = Math.max(minimumHeight, naturalHeight) + const offsetX = worldMargin - left + (width - naturalWidth) / 2 + const offsetY = worldMargin - top + (height - naturalHeight) / 2 + return { + projects: positioned.map((project) => ({ + ...project, + x: project.x + offsetX, + y: project.y + offsetY + })), + width, + height + } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts b/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts new file mode 100644 index 00000000000..a341fdd1767 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-spawn-clustering.ts @@ -0,0 +1,27 @@ +import type { DashboardCard } from '../../../../shared/dashboard-snapshot' + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +export function selectAgentMapSpawnParentContainer( + cards: readonly DashboardCard[], + cardsByPaneKey: ReadonlyMap, + containerIdentity: (card: DashboardCard) => string +): string | undefined { + const ownContainerId = cards[0] ? containerIdentity(cards[0]) : undefined + const linkCounts = new Map() + for (const card of cards) { + const parent = card.parentPaneKey ? cardsByPaneKey.get(card.parentPaneKey) : undefined + const parentContainerId = parent ? containerIdentity(parent) : undefined + if (!parentContainerId || parentContainerId === ownContainerId) { + continue + } + linkCounts.set(parentContainerId, (linkCounts.get(parentContainerId) ?? 0) + 1) + } + return [...linkCounts] + .sort(([leftId, leftCount], [rightId, rightCount]) => + rightCount !== leftCount ? rightCount - leftCount : compareStable(leftId, rightId) + ) + .at(0)?.[0] +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts b/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts new file mode 100644 index 00000000000..6fe03f6f585 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-viewport-transition.ts @@ -0,0 +1,56 @@ +export type AgentMapViewport = { + center: { x: number; y: number } + zoom: number +} + +type ViewportTransitionOptions = { + from: AgentMapViewport + to: AgentMapViewport + durationMs: number + onFrame: (viewport: AgentMapViewport) => void + onComplete?: () => void +} + +function interpolate(from: number, to: number, progress: number): number { + return from + (to - from) * progress +} + +export function startAgentMapViewportTransition({ + from, + to, + durationMs, + onFrame, + onComplete +}: ViewportTransitionOptions): () => void { + let frameId: number | null = null + let startedAt: number | null = null + let cancelled = false + const tick = (now: number): void => { + if (cancelled) { + return + } + startedAt ??= now + const progress = Math.min(1, (now - startedAt) / durationMs) + const eased = 1 - (1 - progress) ** 3 + onFrame({ + center: { + x: interpolate(from.center.x, to.center.x, eased), + y: interpolate(from.center.y, to.center.y, eased) + }, + zoom: interpolate(from.zoom, to.zoom, eased) + }) + if (progress < 1) { + frameId = requestAnimationFrame(tick) + } else { + frameId = null + onComplete?.() + } + } + frameId = requestAnimationFrame(tick) + return () => { + cancelled = true + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts new file mode 100644 index 00000000000..5ceaf4e93eb --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-identity.ts @@ -0,0 +1,29 @@ +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' + +export function agentMapCardTopologyIdentity(card: DashboardCard): string { + const parentPaneKey = card.parentPaneKey ?? '' + const parentWorktreeId = card.parentWorktreeId ?? '' + const executionHostId = card.executionHostId ?? '' + return `${card.repoId.length}:${card.repoId}${card.worktreeId.length}:${card.worktreeId}${executionHostId.length}:${executionHostId}${card.paneKey.length}:${card.paneKey}${parentPaneKey.length}:${parentPaneKey}${parentWorktreeId.length}:${parentWorktreeId}` +} + +export function agentMapWorkspaceTopologyIdentity(workspace: DashboardWorkspace): string { + const parentWorktreeId = workspace.parentWorktreeId ?? '' + return `${workspace.repoId.length}:${workspace.repoId}${workspace.worktreeId.length}:${workspace.worktreeId}${workspace.executionHostId.length}:${workspace.executionHostId}${parentWorktreeId.length}:${parentWorktreeId}` +} + +export function agentMapWorktreeIdentityFromParts( + worktreeId: string, + executionHostId: DashboardCard['executionHostId'] +): string { + const hostId = executionHostId ?? '' + return `${worktreeId.length}:${worktreeId}${hostId.length}:${hostId}` +} + +export function agentMapWorktreeIdentity(card: DashboardCard): string { + return agentMapWorktreeIdentityFromParts(card.worktreeId, card.executionHostId) +} + +export function agentMapWorkspaceIdentity(workspace: DashboardWorkspace): string { + return agentMapWorktreeIdentityFromParts(workspace.worktreeId, workspace.executionHostId) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts new file mode 100644 index 00000000000..1a9593af373 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { EMPTY_DASHBOARD_FILTERS } from './agent-board-filtering' +import { selectAgentlessMapWorkspaces } from './agent-map-workspace-visibility' + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: null, + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: '', + repoId: 'repo-1', + worktreeId: 'occupied', + tabId: 'tab-1', + leafId: null, + repoName: 'Orca', + worktreeName: 'Occupied', + executionHostId: 'local', + startedAt: 0, + finishedAt: null, + stateChangedAt: 0, + unseen: false, + ...overrides + } +} + +function workspace(overrides: Partial = {}): DashboardWorkspace { + return { + repoId: 'repo-1', + worktreeId: 'empty', + repoName: 'Orca', + worktreeName: 'Empty child', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree', + workspaceStatusId: 'planned', + ...overrides + } +} + +describe('agent map workspace visibility', () => { + it('returns only workspaces that have no dashboard card on the same host', () => { + const result = selectAgentlessMapWorkspaces({ + cards: [card()], + workspaces: [ + workspace({ worktreeId: 'occupied', worktreeName: 'Occupied' }), + workspace(), + workspace({ + worktreeId: 'occupied', + worktreeName: 'Remote twin', + hostKind: 'ssh', + executionHostId: 'ssh:build-box' + }) + ], + query: '', + filters: EMPTY_DASHBOARD_FILTERS + }) + + expect(result.map((item) => item.worktreeName)).toEqual(['Empty child', 'Remote twin']) + }) + + it('applies search and workspace filters to agentless workspaces', () => { + const result = selectAgentlessMapWorkspaces({ + cards: [], + workspaces: [ + workspace({ worktreeName: 'Listener security', review: { number: 42, state: 'open' } }), + workspace({ worktreeId: 'other', worktreeName: 'Unrelated', workspaceStatusId: 'active' }) + ], + query: 'listener', + filters: { + projects: ['repo-1'], + workspaceStatuses: ['planned'], + reviewStates: ['open'] + } + }) + + expect(result.map((item) => item.worktreeName)).toEqual(['Listener security']) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts new file mode 100644 index 00000000000..311dd95e2f6 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-workspace-visibility.ts @@ -0,0 +1,25 @@ +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { filterDashboardWorkspaces, type DashboardFilters } from './agent-board-filtering' +import { agentMapWorktreeIdentityFromParts } from './agent-map-workspace-identity' + +export function selectAgentlessMapWorkspaces({ + cards, + workspaces, + query, + filters +}: { + cards: DashboardCard[] + workspaces: DashboardWorkspace[] + query: string + filters: DashboardFilters +}): DashboardWorkspace[] { + const occupiedWorkspaceIds = new Set( + cards.map((card) => agentMapWorktreeIdentityFromParts(card.worktreeId, card.executionHostId)) + ) + return filterDashboardWorkspaces(workspaces, query, filters).filter( + (workspace) => + !occupiedWorkspaceIds.has( + agentMapWorktreeIdentityFromParts(workspace.worktreeId, workspace.executionHostId) + ) + ) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts new file mode 100644 index 00000000000..c02eb68ed27 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import type { AgentMapStatusCounts } from './agent-map-layout' +import { agentMapWorktreeActiveStatus } from './agent-map-worktree-active-status' + +function counts(overrides: Partial = {}): AgentMapStatusCounts { + return { blocked: 0, waiting: 0, working: 0, done: 0, idle: 0, ...overrides } +} + +describe('agentMapWorktreeActiveStatus', () => { + it('prioritizes attention over working', () => { + expect(agentMapWorktreeActiveStatus(counts({ working: 2, waiting: 1 }))).toBe('waiting') + expect(agentMapWorktreeActiveStatus(counts({ working: 2, waiting: 1, blocked: 1 }))).toBe( + 'blocked' + ) + }) + + it('uses working only when no agent needs attention', () => { + expect(agentMapWorktreeActiveStatus(counts({ working: 1, done: 2 }))).toBe('working') + expect(agentMapWorktreeActiveStatus(counts({ done: 2, idle: 1 }))).toBeNull() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts new file mode 100644 index 00000000000..925a80940e5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-active-status.ts @@ -0,0 +1,15 @@ +import type { AgentMapStatusCounts } from './agent-map-layout' + +export type AgentMapWorktreeActiveStatus = 'blocked' | 'waiting' | 'working' + +export function agentMapWorktreeActiveStatus( + counts: AgentMapStatusCounts +): AgentMapWorktreeActiveStatus | null { + if (counts.blocked > 0) { + return 'blocked' + } + if (counts.waiting > 0) { + return 'waiting' + } + return counts.working > 0 ? 'working' : null +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts new file mode 100644 index 00000000000..8bfb9fc28d4 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_MAP_WORKTREE_GAP } from './agent-map-worktree-packing' +import { layoutAgentMapWorktreeLineage } from './agent-map-worktree-lineage-layout' + +function buildChain(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `worktree-${index.toString().padStart(4, '0')}`, + parentId: index === 0 ? undefined : `worktree-${(index - 1).toString().padStart(4, '0')}`, + radius: 32, + x: 0, + y: 0 + })) +} + +function buildComb(spineCount: number) { + const worktrees: ReturnType = [] + for (let index = 0; index < spineCount; index += 1) { + const suffix = index.toString().padStart(4, '0') + worktrees.push({ + id: `spine-${suffix}`, + parentId: index === 0 ? undefined : `spine-${(index - 1).toString().padStart(4, '0')}`, + radius: 32, + x: 0, + y: 0 + }) + if (index < spineCount - 1) { + worktrees.push({ + id: `leaf-${suffix}`, + parentId: `spine-${suffix}`, + radius: 24, + x: 0, + y: 0 + }) + } + } + return worktrees +} + +function layoutWithNumericMapSetCount(worktrees: ReturnType) { + const set = Map.prototype.set + let numericMapSets = 0 + Map.prototype.set = function (this: Map, key: unknown, value: unknown) { + if (typeof key === 'number') { + numericMapSets += 1 + } + return Reflect.apply(set, this, [key, value]) + } as typeof Map.prototype.set + try { + return { layout: layoutAgentMapWorktreeLineage(worktrees), numericMapSets } + } finally { + Map.prototype.set = set + } +} + +function layoutWithWorktreePushCount(count: number) { + const push = Array.prototype.push + let worktreePushes = 0 + Array.prototype.push = function (...items: unknown[]): number { + worktreePushes += items.filter( + (item) => + typeof item === 'object' && + item !== null && + 'id' in item && + typeof item.id === 'string' && + item.id.startsWith('worktree-') + ).length + return Reflect.apply(push, this, items) + } + try { + return { + layout: layoutAgentMapWorktreeLineage(buildChain(count)), + worktreePushes + } + } finally { + Array.prototype.push = push + } +} + +describe('layoutAgentMapWorktreeLineage', () => { + it('keeps branched and linear family coordinates deterministic', () => { + const layout = layoutAgentMapWorktreeLineage([ + { id: 'root', x: 0, y: 0, radius: 40 }, + { id: 'child-a', parentId: 'root', x: 0, y: 0, radius: 30 }, + { id: 'grandchild-a', parentId: 'child-a', x: 0, y: 0, radius: 25 }, + { id: 'child-b', parentId: 'root', x: 0, y: 0, radius: 45 }, + { id: 'second-root', x: 0, y: 0, radius: 35 }, + { id: 'second-child', parentId: 'second-root', x: 0, y: 0, radius: 20 } + ]) + + expect(layout).toEqual([ + { + id: 'child-a', + parentId: 'root', + radius: 30, + x: -7.740689238053122, + y: 42.47172405948656 + }, + { + id: 'child-b', + parentId: 'root', + radius: 45, + x: 69.93546272327787, + y: 175.54837055839306 + }, + { + id: 'grandchild-a', + parentId: 'child-a', + radius: 25, + x: -7.740689238053122, + y: 125.47172405948656 + }, + { id: 'root', radius: 40, x: 19.097386742612372, y: -55.52827594051344 }, + { + id: 'second-child', + parentId: 'second-root', + radius: 20, + x: -112.9872940755618, + y: -73.65876088400047 + }, + { + id: 'second-root', + radius: 35, + x: -112.9872940755618, + y: -156.65876088400046 + } + ]) + }) + + it('flattens a 1,000-worktree lineage once', () => { + const { layout, worktreePushes } = layoutWithWorktreePushCount(1_000) + + expect(layout).toHaveLength(1_000) + expect(worktreePushes).toBeLessThan(5_000) + for (let index = 1; index < layout.length; index += 1) { + expect(layout[index].y).toBeGreaterThan(layout[index - 1].y) + expect(layout[index].y - layout[index - 1].y).toBeGreaterThanOrEqual( + layout[index].radius + layout[index - 1].radius + AGENT_MAP_WORKTREE_GAP + ) + } + }) + + it.each([ + [399, 200], + [999, 500] + ])('avoids spatial-grid expansion for a %i-worktree comb', (expectedCount, spineCount) => { + const worktrees = buildComb(spineCount) + const { layout, numericMapSets } = layoutWithNumericMapSetCount(worktrees) + + expect(layout).toHaveLength(expectedCount) + expect(numericMapSets).toBeLessThan(10) + expect(layoutAgentMapWorktreeLineage(worktrees)).toEqual(layout) + }) + + it('keeps a deeply branched lineage finite without recursive stack growth', () => { + const worktrees = buildComb(2_500) + const layout = layoutAgentMapWorktreeLineage(worktrees) + const byId = new Map(layout.map((worktree) => [worktree.id, worktree])) + + expect(layout).toHaveLength(4_999) + expect( + layout.every( + (worktree) => + Number.isFinite(worktree.x) && + Number.isFinite(worktree.y) && + Math.abs(worktree.x) < 1_000_000 && + Math.abs(worktree.y) < 1_000_000 + ) + ).toBe(true) + for (const worktree of layout) { + if (worktree.parentId) { + expect(worktree.y).toBeGreaterThan(byId.get(worktree.parentId)!.y) + } + } + }) + + it('wraps very large worktree fanout without overlap', () => { + const layout = layoutAgentMapWorktreeLineage([ + { id: 'parent', x: 0, y: 0, radius: 32 }, + ...Array.from({ length: 300 }, (_, index) => ({ + id: `child-${index}`, + parentId: 'parent', + x: 0, + y: 0, + radius: 24 + })) + ]) + const parent = layout.find((worktree) => worktree.id === 'parent')! + const children = layout.filter( + (worktree) => 'parentId' in worktree && worktree.parentId === 'parent' + ) + let minimumGap = Number.POSITIVE_INFINITY + + expect(children.every((child) => child.y > parent.y)).toBe(true) + for (const [index, child] of children.entries()) { + for (const other of children.slice(index + 1)) { + minimumGap = Math.min( + minimumGap, + Math.hypot(child.x - other.x, child.y - other.y) - child.radius - other.radius + ) + } + } + expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP) + }) + + it('packs high-fanout spawn clusters without forcing every workspace below the coordinator', () => { + const layout = layoutAgentMapWorktreeLineage([ + { id: 'parent', x: 0, y: 0, radius: 32 }, + ...Array.from({ length: 13 }, (_, index) => ({ + id: `child-${index.toString().padStart(2, '0')}`, + clusterParentId: 'parent', + x: 0, + y: 0, + radius: 24 + })) + ]) + const parent = layout.find((worktree) => worktree.id === 'parent')! + const children = layout.filter( + (worktree) => 'clusterParentId' in worktree && worktree.clusterParentId === 'parent' + ) + + expect(children.some((child) => child.y <= parent.y)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts new file mode 100644 index 00000000000..1a6e2875b65 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-lineage-layout.ts @@ -0,0 +1,279 @@ +import { AGENT_MAP_WORKTREE_GAP, packAgentMapWorktrees } from './agent-map-worktree-packing' + +const LINEAGE_VERTICAL_GAP = 28 +const MAX_HIERARCHICAL_CLUSTER_FANOUT = 12 +const MAX_EXACT_LINEAGE_WORKTREES = 256 + +type LineageWorktree = { + id: string + parentId?: string + clusterParentId?: string + x: number + y: number + radius: number +} + +type WorktreeFamily = { + id: string + x: number + y: number + radius: number + worktrees: T[] +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function encloseFamily(id: string, worktrees: T[]): WorktreeFamily { + const left = Math.min(...worktrees.map((worktree) => worktree.x - worktree.radius)) + const right = Math.max(...worktrees.map((worktree) => worktree.x + worktree.radius)) + const top = Math.min(...worktrees.map((worktree) => worktree.y - worktree.radius)) + const bottom = Math.max(...worktrees.map((worktree) => worktree.y + worktree.radius)) + const centerX = (left + right) / 2 + const centerY = (top + bottom) / 2 + for (const worktree of worktrees) { + worktree.x -= centerX + worktree.y -= centerY + } + return { + id, + x: 0, + y: 0, + radius: Math.max( + ...worktrees.map((worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius) + ), + worktrees + } +} + +function buildFamily( + root: T, + childrenByParent: ReadonlyMap, + emitted: Set, + ancestors: ReadonlySet +): WorktreeFamily { + emitted.add(root.id) + const nextAncestors = new Set(ancestors) + nextAncestors.add(root.id) + const children = (childrenByParent.get(root.id) ?? []).filter( + (child) => !nextAncestors.has(child.id) && !emitted.has(child.id) + ) + if (children.length === 0) { + return { + id: root.id, + x: 0, + y: 0, + radius: root.radius, + worktrees: [{ ...root, x: 0, y: 0 }] + } + } + + const childFamilies = packAgentMapWorktrees( + children.map((child) => buildExactFamily(child, childrenByParent, emitted)) + ) + const childLeft = Math.min(...childFamilies.map((family) => family.x - family.radius)) + const childRight = Math.max(...childFamilies.map((family) => family.x + family.radius)) + const childTop = Math.min(...childFamilies.map((family) => family.y - family.radius)) + const childOffsetX = -(childLeft + childRight) / 2 + const childOffsetY = root.radius + LINEAGE_VERTICAL_GAP - childTop + const worktrees = [{ ...root, x: 0, y: 0 }] + for (const family of childFamilies) { + for (const worktree of family.worktrees) { + worktrees.push({ + ...worktree, + x: worktree.x + family.x + childOffsetX, + y: worktree.y + family.y + childOffsetY + }) + } + } + return encloseFamily(root.id, worktrees) +} + +function collectLinearFamily( + root: T, + childrenByParent: ReadonlyMap, + emitted: ReadonlySet +): T[] | null { + const worktrees: T[] = [] + const ancestors = new Set() + let current: T | undefined = root + while (current) { + worktrees.push(current) + ancestors.add(current.id) + const children = (childrenByParent.get(current.id) ?? []).filter( + (child) => !ancestors.has(child.id) && !emitted.has(child.id) + ) + if (children.length > 1) { + return null + } + current = children[0] + } + return worktrees +} + +function buildLinearFamily(worktrees: T[]): WorktreeFamily { + const positioned = worktrees.map((worktree) => ({ ...worktree, x: 0, y: 0 })) + let radius = worktrees.at(-1)?.radius ?? 0 + for (let index = worktrees.length - 2; index >= 0; index -= 1) { + positioned[index].y = -(radius + LINEAGE_VERTICAL_GAP / 2) + radius += worktrees[index].radius + LINEAGE_VERTICAL_GAP / 2 + } + let familyCenterY = 0 + for (let index = 0; index < positioned.length; index += 1) { + positioned[index].y += familyCenterY + familyCenterY += worktrees[index].radius + LINEAGE_VERTICAL_GAP / 2 + } + return { id: worktrees[0].id, x: 0, y: 0, radius, worktrees: positioned } +} + +function buildExactFamily( + root: T, + childrenByParent: ReadonlyMap, + emitted: Set +): WorktreeFamily { + const linearFamily = collectLinearFamily(root, childrenByParent, emitted) + if (!linearFamily) { + return buildFamily(root, childrenByParent, emitted, new Set()) + } + for (const worktree of linearFamily) { + emitted.add(worktree.id) + } + return buildLinearFamily(linearFamily) +} + +function layoutBoundedLineage( + sorted: T[], + childrenByParent: ReadonlyMap, + childIds: ReadonlySet +): T[] { + const levels: T[][] = [] + const emitted = new Set() + const roots = sorted.filter((worktree) => !childIds.has(worktree.id)) + + for (const seed of [...roots, ...sorted]) { + if (emitted.has(seed.id)) { + continue + } + const stack = [{ depth: 0, worktree: seed }] + while (stack.length > 0) { + const entry = stack.pop()! + if (emitted.has(entry.worktree.id)) { + continue + } + emitted.add(entry.worktree.id) + const level = levels[entry.depth] ?? [] + levels[entry.depth] = level + level.push(entry.worktree) + const children = childrenByParent.get(entry.worktree.id) ?? [] + for (let index = children.length - 1; index >= 0; index -= 1) { + if (!emitted.has(children[index].id)) { + stack.push({ depth: entry.depth + 1, worktree: children[index] }) + } + } + } + } + + const positioned: T[] = [] + let y = 0 + let previousMaxRadius = 0 + let hasPositionedRow = false + for (const level of levels) { + const columns = Math.ceil(Math.sqrt(level.length)) + for (let rowStart = 0; rowStart < level.length; rowStart += columns) { + const row = level.slice(rowStart, rowStart + columns) + let maxRadius = 0 + let width = -AGENT_MAP_WORKTREE_GAP + for (const worktree of row) { + maxRadius = Math.max(maxRadius, worktree.radius) + width += worktree.radius * 2 + AGENT_MAP_WORKTREE_GAP + } + if (hasPositionedRow) { + y += previousMaxRadius + maxRadius + LINEAGE_VERTICAL_GAP + } + let x = -width / 2 + for (const worktree of row) { + positioned.push({ ...worktree, x: x + worktree.radius, y }) + x += worktree.radius * 2 + AGENT_MAP_WORKTREE_GAP + } + previousMaxRadius = maxRadius + hasPositionedRow = true + } + } + + let left = Number.POSITIVE_INFINITY + let right = Number.NEGATIVE_INFINITY + let top = Number.POSITIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + for (const worktree of positioned) { + left = Math.min(left, worktree.x - worktree.radius) + right = Math.max(right, worktree.x + worktree.radius) + top = Math.min(top, worktree.y - worktree.radius) + bottom = Math.max(bottom, worktree.y + worktree.radius) + } + const centerX = (left + right) / 2 + const centerY = (top + bottom) / 2 + return positioned + .map((worktree) => ({ ...worktree, x: worktree.x - centerX, y: worktree.y - centerY })) + .sort((a, b) => compareStable(a.id, b.id)) +} + +export function layoutAgentMapWorktreeLineage(worktrees: T[]): T[] { + const sorted = [...worktrees].sort((a, b) => compareStable(a.id, b.id)) + const worktreesById = new Map(sorted.map((worktree) => [worktree.id, worktree])) + const clusterChildCounts = new Map() + for (const worktree of sorted) { + if (worktree.clusterParentId && worktreesById.has(worktree.clusterParentId)) { + clusterChildCounts.set( + worktree.clusterParentId, + (clusterChildCounts.get(worktree.clusterParentId) ?? 0) + 1 + ) + } + } + const childrenByParent = new Map() + const childIds = new Set() + for (const worktree of sorted) { + const clusterParentId = worktree.clusterParentId + const parentId = + clusterParentId && + (clusterChildCounts.get(clusterParentId) ?? 0) <= MAX_HIERARCHICAL_CLUSTER_FANOUT + ? clusterParentId + : worktree.parentId + if (!parentId || parentId === worktree.id || !worktreesById.has(parentId)) { + continue + } + childIds.add(worktree.id) + const siblings = childrenByParent.get(parentId) + if (siblings) { + siblings.push(worktree) + } else { + childrenByParent.set(parentId, [worktree]) + } + } + if (sorted.length > MAX_EXACT_LINEAGE_WORKTREES) { + return layoutBoundedLineage(sorted, childrenByParent, childIds) + } + + const emitted = new Set() + const families: WorktreeFamily[] = [] + for (const root of sorted.filter((worktree) => !childIds.has(worktree.id))) { + if (!emitted.has(root.id)) { + families.push(buildExactFamily(root, childrenByParent, emitted)) + } + } + for (const worktree of sorted) { + if (!emitted.has(worktree.id)) { + families.push(buildExactFamily(worktree, childrenByParent, emitted)) + } + } + + return packAgentMapWorktrees(families) + .flatMap((family) => + family.worktrees.map((worktree) => ({ + ...worktree, + x: worktree.x + family.x, + y: worktree.y + family.y + })) + ) + .sort((a, b) => compareStable(a.id, b.id)) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts new file mode 100644 index 00000000000..a07490dab92 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_MAP_WORKTREE_GAP, packAgentMapWorktrees } from './agent-map-worktree-packing' + +function circles(count = 80): { id: string; x: number; y: number; radius: number }[] { + return Array.from({ length: count }, (_, index) => ({ + id: `worktree-${index.toString().padStart(2, '0')}`, + x: 0, + y: 0, + radius: 28 + (index % 7) * 13 + })) +} + +function measuredCircles(count = 80): { + worktrees: ReturnType + coordinateReads: () => number +} { + let reads = 0 + const worktrees = circles(count).map(({ id, radius }) => { + let x = 0 + let y = 0 + return { + id, + radius, + get x() { + reads += 1 + return x + }, + set x(value: number) { + x = value + }, + get y() { + reads += 1 + return y + }, + set y(value: number) { + y = value + } + } + }) + return { worktrees, coordinateReads: () => reads } +} + +describe('packAgentMapWorktrees', () => { + it('keeps variable-radius rings deterministic and non-overlapping', () => { + const first = packAgentMapWorktrees(circles()) + const second = packAgentMapWorktrees(circles()) + + expect(second).toEqual(first) + for (const [index, worktree] of first.entries()) { + for (const other of first.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('indexes rings that span multiple positive and negative grid cells', () => { + const packed = packAgentMapWorktrees( + [380, 260, 170, 145, 90].map((radius, index) => ({ + id: `large-${index}`, + x: 0, + y: 0, + radius + })) + ) + + expect(packed.some((worktree) => worktree.x < 0 || worktree.y < 0)).toBe(true) + for (const [index, worktree] of packed.entries()) { + for (const other of packed.slice(index + 1)) { + expect(Math.hypot(worktree.x - other.x, worktree.y - other.y)).toBeGreaterThanOrEqual( + worktree.radius + other.radius + AGENT_MAP_WORKTREE_GAP - 0.001 + ) + } + } + }) + + it('keeps capped large-map packing deterministic and compact', () => { + const first = packAgentMapWorktrees(circles(300)) + const second = packAgentMapWorktrees(circles(300)) + + expect(second).toEqual(first) + expect( + Math.max(...first.map((worktree) => Math.hypot(worktree.x, worktree.y) + worktree.radius)) + ).toBeLessThan(1_500) + let minimumGap = Number.POSITIVE_INFINITY + for (const [index, worktree] of first.entries()) { + for (const other of first.slice(index + 1)) { + minimumGap = Math.min( + minimumGap, + Math.hypot(worktree.x - other.x, worktree.y - other.y) - worktree.radius - other.radius + ) + } + } + expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP - 0.001) + }) + + it('bounds deterministic coordinate checks for larger maps', () => { + const { worktrees, coordinateReads } = measuredCircles(300) + + packAgentMapWorktrees(worktrees) + + expect(coordinateReads()).toBeLessThan(13_200_000) + }) + + it('bounds packing work for a thousand rings', () => { + const { worktrees, coordinateReads } = measuredCircles(1_000) + const packed = packAgentMapWorktrees(worktrees) + const positions = packed.map(({ id, x, y, radius }) => ({ id, x, y, radius })) + + expect(packed).toHaveLength(1_000) + expect( + packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y)) + ).toBe(true) + expect(coordinateReads()).toBeLessThan(10_000_000) + expect(packAgentMapWorktrees(circles(1_000))).toEqual(positions) + let minimumGap = Number.POSITIVE_INFINITY + for (const [index, worktree] of positions.entries()) { + for (const other of positions.slice(index + 1)) { + minimumGap = Math.min( + minimumGap, + Math.hypot(worktree.x - other.x, worktree.y - other.y) - worktree.radius - other.radius + ) + } + } + expect(minimumGap).toBeGreaterThanOrEqual(AGENT_MAP_WORKTREE_GAP - 0.001) + }) + + it('bounds packing work when one ring dwarfs the rest', () => { + const { worktrees, coordinateReads } = measuredCircles(1_000) + worktrees[0].radius = 50_000_000 + const packed = packAgentMapWorktrees(worktrees) + + expect(packed).toHaveLength(1_000) + expect( + packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y)) + ).toBe(true) + expect(coordinateReads()).toBeLessThan(10_000_000) + }) + + it('keeps the spatial index bounded for very large rings', () => { + const set = Map.prototype.set + let numericMapSets = 0 + Map.prototype.set = function (this: Map, key: unknown, value: unknown) { + if (typeof key === 'number') { + numericMapSets += 1 + } + return Reflect.apply(set, this, [key, value]) + } as typeof Map.prototype.set + try { + const packed = packAgentMapWorktrees( + Array.from({ length: 5 }, (_, index) => ({ + id: `huge-${index}`, + x: 0, + y: 0, + radius: 50_000_000 - index * 1_000_000 + })) + ) + + expect( + packed.every((worktree) => Number.isFinite(worktree.x) && Number.isFinite(worktree.y)) + ).toBe(true) + expect(numericMapSets).toBeLessThan(100) + } finally { + Map.prototype.set = set + } + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts new file mode 100644 index 00000000000..4dab5ac2346 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-worktree-packing.ts @@ -0,0 +1,267 @@ +import { + addAgentMapPackingCircle, + agentMapPackingCircleOverlaps, + AGENT_MAP_PACKING_SCORE_TOLERANCE, + AGENT_MAP_WORKTREE_GAP, + type AgentMapPackableCircle, + type AgentMapPackingSpatialIndex +} from './agent-map-packing-spatial-index' + +export { AGENT_MAP_WORKTREE_GAP } from './agent-map-packing-spatial-index' + +const PACKING_ANGLE_STEPS = 72 +const MAX_PACKING_CANDIDATE_ANCHORS = 128 +const MAX_DIRECT_OVERLAP_WORKTREES = 4 +const LARGE_PACKING_THRESHOLD = 256 +const VERY_LARGE_PACKING_THRESHOLD = 512 +const SCORE_TOLERANCE = AGENT_MAP_PACKING_SCORE_TOLERANCE +const CENTER_DIRECTIONS = [ + [-1, -1], + [0, -1], + [1, -1], + [-1, 0], + [1, 0], + [-1, 1], + [0, 1], + [1, 1] +] as const + +type PackableWorktree = AgentMapPackableCircle + +type PackingCandidate = { + x: number + y: number + enclosingRadius: number + distanceFromCenter: number + neighborDistance?: number +} + +type PackingSearchBudget = { + angleSteps: number + candidateAnchors: number +} + +function compareStable(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0 +} + +function hashFraction(value: string): number { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0) / 0xffffffff +} + +function placedWorktreesOverlap( + candidate: Pick, + placed: PackableWorktree[] +): boolean { + return placed.some( + (worktree) => + Math.hypot(candidate.x - worktree.x, candidate.y - worktree.y) < + candidate.radius + worktree.radius + AGENT_MAP_WORKTREE_GAP - SCORE_TOLERANCE + ) +} + +function comparePackingScores( + a: PackingCandidate, + b: PackingCandidate, + placed: PackableWorktree[] +): number { + for (const key of ['enclosingRadius', 'distanceFromCenter'] as const) { + if (Math.abs(a[key] - b[key]) > SCORE_TOLERANCE) { + return a[key] - b[key] + } + } + a.neighborDistance ??= placed.reduce( + (sum, other) => sum + Math.hypot(a.x - other.x, a.y - other.y), + 0 + ) + b.neighborDistance ??= placed.reduce( + (sum, other) => sum + Math.hypot(b.x - other.x, b.y - other.y), + 0 + ) + return Math.abs(a.neighborDistance - b.neighborDistance) > SCORE_TOLERANCE + ? a.neighborDistance - b.neighborDistance + : 0 +} + +function compareBoundaryAnchors(a: PackableWorktree, b: PackableWorktree): number { + return ( + Math.hypot(b.x, b.y) + b.radius - (Math.hypot(a.x, a.y) + a.radius) || compareStable(a.id, b.id) + ) +} + +function addBoundaryAnchor( + boundaryAnchors: PackableWorktree[], + worktree: PackableWorktree, + maxAnchors: number +): void { + let low = 0 + let high = boundaryAnchors.length + while (low < high) { + const middle = (low + high) >>> 1 + if (compareBoundaryAnchors(worktree, boundaryAnchors[middle]) < 0) { + high = middle + } else { + low = middle + 1 + } + } + boundaryAnchors.splice(low, 0, worktree) + if (boundaryAnchors.length > maxAnchors) { + boundaryAnchors.pop() + } +} + +function placePackedWorktree( + worktree: PackableWorktree, + placed: PackableWorktree[], + boundaryAnchors: PackableWorktree[], + spatialIndex: AgentMapPackingSpatialIndex | null, + currentRadius: number, + searchBudget: PackingSearchBudget +): void { + let best: PackingCandidate | undefined + + const anchors = placed.length <= searchBudget.candidateAnchors ? placed : boundaryAnchors + const scoreNeighbors = + searchBudget.candidateAnchors === MAX_PACKING_CANDIDATE_ANCHORS ? placed : anchors + for (const anchor of anchors) { + const orbit = anchor.radius + worktree.radius + AGENT_MAP_WORKTREE_GAP + const angleOffset = hashFraction(`${worktree.id}:${anchor.id}`) * Math.PI * 2 + for (let step = 0; step < searchBudget.angleSteps; step += 1) { + const angle = angleOffset + (step / searchBudget.angleSteps) * Math.PI * 2 + const x = anchor.x + Math.cos(angle) * orbit + const y = anchor.y + Math.sin(angle) * orbit + const overlapCandidate = { x, y, radius: worktree.radius } + if ( + spatialIndex + ? agentMapPackingCircleOverlaps(overlapCandidate, spatialIndex) + : placedWorktreesOverlap(overlapCandidate, placed) + ) { + continue + } + const distanceFromCenter = Math.hypot(x, y) + const candidate = { + x, + y, + enclosingRadius: Math.max(currentRadius, distanceFromCenter + worktree.radius), + distanceFromCenter + } + if (!best || comparePackingScores(candidate, best, scoreNeighbors) < 0) { + best = candidate + } + } + } + + if (best) { + worktree.x = best.x + worktree.y = best.y + return + } + let fallbackX = Number.NEGATIVE_INFINITY + for (const candidate of placed) { + fallbackX = Math.max(fallbackX, candidate.x + candidate.radius) + } + worktree.x = fallbackX + worktree.radius + AGENT_MAP_WORKTREE_GAP + worktree.y = 0 +} + +function enclosingRadius(worktrees: PackableWorktree[], x: number, y: number): number { + let radius = 0 + for (const worktree of worktrees) { + radius = Math.max(radius, Math.hypot(worktree.x - x, worktree.y - y) + worktree.radius) + } + return radius +} + +function packingSearchBudget(count: number): PackingSearchBudget { + if (count > VERY_LARGE_PACKING_THRESHOLD) { + return { angleSteps: 16, candidateAnchors: 12 } + } + if (count > LARGE_PACKING_THRESHOLD) { + return { angleSteps: 24, candidateAnchors: 64 } + } + return { + angleSteps: PACKING_ANGLE_STEPS, + candidateAnchors: MAX_PACKING_CANDIDATE_ANCHORS + } +} + +function findEnclosingCenter( + worktrees: PackableWorktree[], + bounds: { left: number; right: number; top: number; bottom: number } +): { x: number; y: number } { + let x = (bounds.left + bounds.right) / 2 + let y = (bounds.top + bounds.bottom) / 2 + let radius = enclosingRadius(worktrees, x, y) + let step = Math.max(bounds.right - bounds.left, bounds.bottom - bounds.top) / 4 + + while (step > SCORE_TOLERANCE) { + let improved = false + for (const [dx, dy] of CENTER_DIRECTIONS) { + const candidateX = x + dx * step + const candidateY = y + dy * step + const candidateRadius = enclosingRadius(worktrees, candidateX, candidateY) + if (candidateRadius < radius - SCORE_TOLERANCE) { + x = candidateX + y = candidateY + radius = candidateRadius + improved = true + } + } + if (!improved) { + step /= 2 + } + } + return { x, y } +} + +export function packAgentMapWorktrees(worktrees: T[]): T[] { + const packed = [...worktrees].sort((a, b) => b.radius - a.radius || compareStable(a.id, b.id)) + const placed: PackableWorktree[] = [] + const boundaryAnchors: PackableWorktree[] = [] + const searchBudget = packingSearchBudget(packed.length) + const spatialIndex: AgentMapPackingSpatialIndex | null = + packed.length > MAX_DIRECT_OVERLAP_WORKTREES ? new Map() : null + let currentRadius = 0 + for (const worktree of packed) { + if (placed.length > 0) { + placePackedWorktree( + worktree, + placed, + boundaryAnchors, + spatialIndex, + currentRadius, + searchBudget + ) + } + placed.push(worktree) + addBoundaryAnchor(boundaryAnchors, worktree, searchBudget.candidateAnchors) + if (spatialIndex) { + addAgentMapPackingCircle(spatialIndex, worktree) + } + currentRadius = Math.max(currentRadius, Math.hypot(worktree.x, worktree.y) + worktree.radius) + } + if (packed.length === 0) { + return packed + } + let left = Number.POSITIVE_INFINITY + let right = Number.NEGATIVE_INFINITY + let top = Number.POSITIVE_INFINITY + let bottom = Number.NEGATIVE_INFINITY + for (const worktree of packed) { + left = Math.min(left, worktree.x - worktree.radius) + right = Math.max(right, worktree.x + worktree.radius) + top = Math.min(top, worktree.y - worktree.radius) + bottom = Math.max(bottom, worktree.y + worktree.radius) + } + const center = findEnclosingCenter(packed, { left, right, top, bottom }) + for (const worktree of packed) { + worktree.x -= center.x + worktree.y -= center.y + } + return packed.sort((a, b) => compareStable(a.id, b.id)) +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css new file mode 100644 index 00000000000..1170931f1b8 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -0,0 +1,335 @@ +.agent-map-canvas { + background-image: radial-gradient( + color-mix(in srgb, var(--muted-foreground) 16%, transparent) 0.6px, + transparent 0.6px + ); + background-size: 20px 20px; +} + +.agent-map-canvas::before { + position: absolute; + inset: 0; + background: radial-gradient( + circle at 45% 48%, + color-mix(in srgb, var(--muted) 34%, transparent), + transparent 70% + ); + content: ''; + pointer-events: none; +} + +.agent-map-project-ring { + fill: color-mix(in srgb, var(--card) 22%, transparent); + stroke: color-mix(in srgb, var(--ring) 58%, transparent); + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.agent-map-worktree-label { + fill: var(--foreground); + font-family: Geist, var(--font-sans); + font-weight: 600; + letter-spacing: 0.05em; + paint-order: stroke fill; + pointer-events: none; + stroke: var(--background); + stroke-linejoin: round; + stroke-width: 4px; +} + +.agent-map-project-label-frame { + overflow: visible; + pointer-events: none; +} + +.agent-map-project-label { + display: flex; + width: max-content; + max-width: calc(100% - 8px); + height: 100%; + align-items: center; + justify-content: center; + gap: 4px; + margin-inline: auto; + color: var(--foreground); + font-family: Geist, var(--font-sans); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.05em; + line-height: 1; + white-space: nowrap; +} + +.agent-map-project-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + -webkit-text-stroke: 4px var(--background); + paint-order: stroke fill; +} + +.agent-map-project-count, +.agent-map-worktree-count { + fill: var(--muted-foreground); + font-family: Geist, var(--font-sans); + letter-spacing: 0.05em; + paint-order: stroke fill; + pointer-events: none; + stroke: var(--background); + stroke-linejoin: round; + stroke-width: 4px; +} + +.agent-map-project-count { + font-size: 11px; + text-anchor: middle; +} + +.agent-map-worktree-ring { + cursor: pointer; + fill: color-mix(in srgb, var(--card) 48%, transparent); + outline: none; + stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.agent-map-worktree-status-glow { + fill: none; + pointer-events: none; + stroke-width: 9; + vector-effect: non-scaling-stroke; +} + +.agent-map-worktree-status-glow.fleet-status-blocked { + stroke: color-mix(in srgb, var(--color-red-500) 28%, transparent); +} + +.agent-map-worktree-status-glow.fleet-status-waiting { + stroke: color-mix(in srgb, var(--color-amber-500) 28%, transparent); +} + +.agent-map-worktree-status-glow.fleet-status-working { + stroke: color-mix(in srgb, var(--color-yellow-500) 28%, transparent); +} + +.agent-map-worktree-ring:hover { + fill: color-mix(in srgb, var(--card) 72%, transparent); + stroke: var(--ring); +} + +.agent-map-worktree-ring:focus-visible { + fill: color-mix(in srgb, var(--ring) 8%, var(--card)); + outline: none; + stroke: var(--ring); + stroke-width: 2; +} + +.agent-map-worktree-ring.is-selected { + fill: color-mix(in srgb, var(--ring) 6%, var(--card)); + stroke: var(--ring); + stroke-width: 1.5; +} + +.agent-map-worktree-ring.is-open { + fill: color-mix(in srgb, var(--ring) 18%, var(--card)); + stroke: var(--ring); + stroke-width: 2.5; +} + +.agent-map-worktree-ring.is-working { + stroke: var(--color-yellow-500); +} + +.agent-map-worktree-ring.is-waiting { + stroke: var(--color-amber-500); +} + +.agent-map-worktree-ring.is-blocked { + stroke: var(--color-red-500); +} + +.agent-map-worktree-label { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.01em; + text-anchor: middle; +} + +.agent-map-worktree-count { + font-size: 11px; + text-anchor: middle; +} + +.agent-map-worktree-label-group { + opacity: 0; + pointer-events: none; +} + +.agent-map-worktree-label-group.is-visible, +.agent-map-worktree-label-group.is-active { + opacity: 1; +} + +.agent-map-worktree-count { + opacity: 0; +} + +.agent-map-worktree-label-group.is-count-visible .agent-map-worktree-count { + opacity: 1; +} + +.agent-map-agent-node { + cursor: pointer; + outline: none; +} + +.agent-map-worktree-lineage-link { + fill: none; + pointer-events: none; + stroke: color-mix(in srgb, var(--muted-foreground) 34%, transparent); + stroke-linecap: round; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.agent-map-lineage-link { + fill: none; + pointer-events: none; + stroke: color-mix(in srgb, var(--muted-foreground) 42%, transparent); + stroke-dasharray: 1 4; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.agent-map-lineage-link.is-subagent { + stroke-dasharray: none; +} + +.agent-map-agent-hit { + fill: transparent; + stroke: transparent; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.agent-map-agent-mark { + fill: var(--background); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +.agent-map-agent-working-glow { + fill: none; + pointer-events: none; + stroke: color-mix(in srgb, var(--color-yellow-500) 32%, transparent); + stroke-width: 7; + vector-effect: non-scaling-stroke; +} + +.agent-map-agent-icon { + overflow: visible; + pointer-events: none; +} + +.agent-map-agent-icon > div { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + color: var(--foreground); +} + +.agent-map-agent-node:hover .agent-map-agent-hit, +.agent-map-agent-node:focus-visible .agent-map-agent-hit { + fill: color-mix(in srgb, var(--ring) 10%, transparent); + stroke: var(--ring); +} + +.agent-map-agent-node.is-selected .agent-map-agent-hit { + fill: color-mix(in srgb, var(--ring) 18%, transparent); + stroke: var(--ring); + stroke-width: 3; +} + +.agent-map-agent-node.is-selected .agent-map-agent-mark { + fill: color-mix(in srgb, var(--ring) 10%, var(--background)); +} + +.fleet-status-working .agent-map-agent-mark { + stroke: var(--color-yellow-500); +} + +.fleet-status-blocked .agent-map-agent-mark { + stroke: var(--color-red-500); +} + +.fleet-status-waiting .agent-map-agent-mark { + stroke: var(--color-amber-500); +} + +.fleet-status-done .agent-map-agent-mark { + stroke: var(--color-emerald-500); +} + +.fleet-status-idle .agent-map-agent-mark { + stroke: color-mix(in srgb, var(--color-neutral-500) 55%, transparent); +} + +.agent-map-agent-unread-mark { + fill: var(--color-amber-500); + pointer-events: none; + stroke: var(--background); + stroke-width: 2; +} + +.agent-map-aggregate-node circle { + fill: color-mix(in srgb, var(--muted-foreground) 12%, var(--card)); + stroke: color-mix(in srgb, var(--muted-foreground) 46%, transparent); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.agent-map-aggregate-node text { + fill: var(--muted-foreground); + font-family: Geist, var(--font-sans); + font-size: 11px; + font-weight: 600; + text-anchor: middle; +} + +.agent-map-legend-dot { + display: block; + width: 7px; + height: 7px; + border-radius: 9999px; + background: var(--muted-foreground); + opacity: 0.42; +} + +.agent-map-legend-dot.fleet-status-working { + border: 2px solid var(--color-yellow-500); + border-top-color: transparent; + background: transparent; + opacity: 1; +} + +.agent-map-legend-dot.fleet-status-blocked { + background: var(--color-red-500); + opacity: 1; +} + +.agent-map-legend-dot.fleet-status-waiting { + background: var(--color-amber-500); + box-shadow: 0 0 4px color-mix(in srgb, var(--color-amber-500) 72%, transparent); + opacity: 1; +} + +.agent-map-legend-dot.fleet-status-done { + background: var(--color-emerald-500); + opacity: 1; +} diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts new file mode 100644 index 00000000000..98f9ec369c5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch' + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'tab-1:leaf-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'old task', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Repo', + worktreeName: 'Worktree', + startedAt: 100, + finishedAt: null, + stateChangedAt: 100, + statusUpdatedAt: 150, + unseen: false, + ...overrides + } +} + +function event(overrides: Partial = {}): AgentStatusIpcPayload { + return { + paneKey: 'tab-1:leaf-1', + state: 'blocked', + prompt: 'Need a decision', + connectionId: null, + receivedAt: 300, + stateStartedAt: 250, + interactivePrompt: '{"question":"Continue?"}', + ...overrides + } +} + +function snapshot(cards: DashboardCard[] = [card()]): DashboardSnapshot { + return { generatedAt: 200, cards } +} + +describe('patchDashboardSnapshotFromAgentStatus', () => { + it('patches one known card without rebuilding the dashboard topology', () => { + const original = snapshot([card(), card({ paneKey: 'tab-2:leaf-2' })]) + const result = patchDashboardSnapshotFromAgentStatus(original, event()) + + expect(result.matched).toBe(true) + expect(result.snapshot.cards[0]).toMatchObject({ + bucket: 'attention', + dotState: 'blocked', + task: 'Need a decision', + lastUserMessage: 'Need a decision', + askSummary: '{"question":"Continue?"}', + stateChangedAt: 250, + statusUpdatedAt: 300, + unseen: true + }) + expect(result.snapshot.cards[1]).toBe(original.cards[1]) + }) + + it('preserves cached fields omitted from same-state hook pings', () => { + const original = snapshot([ + card({ + bucket: 'attention', + dotState: 'waiting', + askSummary: 'Pick one', + lastAgentMessage: 'Waiting', + stateChangedAt: 250, + unseen: true + }) + ]) + const result = patchDashboardSnapshotFromAgentStatus( + original, + event({ state: 'waiting', prompt: '', interactivePrompt: undefined, stateStartedAt: 250 }) + ) + + expect(result.snapshot.cards[0]).toMatchObject({ + task: 'old task', + askSummary: 'Pick one', + lastAgentMessage: 'Waiting', + unseen: true + }) + }) + + it('ignores stale, wrong-workspace, and session-only events', () => { + const original = snapshot() + expect( + patchDashboardSnapshotFromAgentStatus(original, event({ receivedAt: 150 })).snapshot + ).toBe(original) + expect( + patchDashboardSnapshotFromAgentStatus(original, event({ worktreeId: 'other' })).snapshot + ).toBe(original) + expect( + patchDashboardSnapshotFromAgentStatus(original, event({ providerSessionOnly: true })).snapshot + ).toBe(original) + }) + + it('asks the caller for topology only when the pane is unknown', () => { + const original = snapshot() + const result = patchDashboardSnapshotFromAgentStatus( + original, + event({ paneKey: 'tab-new:leaf-new' }) + ) + + expect(result).toEqual({ matched: false, snapshot: original }) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts new file mode 100644 index 00000000000..f3d32a9b462 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/dashboard-agent-status-patch.ts @@ -0,0 +1,83 @@ +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { + dashboardCardDisplayState, + type DashboardCard, + type DashboardCardSubagent, + type DashboardSnapshot +} from '../../../../shared/dashboard-snapshot' +import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket' + +export type DashboardAgentStatusPatchResult = { + matched: boolean + snapshot: DashboardSnapshot +} + +function patchedSubagents( + card: DashboardCard, + event: AgentStatusIpcPayload +): DashboardCardSubagent[] | undefined { + if (event.subagents === undefined) { + return card.subagents + } + return event.subagents.map((subagent) => ({ + id: `${card.paneKey}\u0000subagent:${subagent.id}`, + name: subagent.description || subagent.agentType || 'unknown', + dotState: subagent.state + })) +} + +/** Applies an already bounded hook event inside the pop-out renderer. */ +export function patchDashboardSnapshotFromAgentStatus( + snapshot: DashboardSnapshot, + event: AgentStatusIpcPayload +): DashboardAgentStatusPatchResult { + if (event.providerSessionOnly) { + return { matched: true, snapshot } + } + const index = snapshot.cards.findIndex((card) => card.paneKey === event.paneKey) + if (index < 0) { + return { matched: false, snapshot } + } + const card = snapshot.cards[index] + if ( + event.receivedAt <= (card.statusUpdatedAt ?? 0) || + (event.worktreeId !== undefined && event.worktreeId !== card.worktreeId) + ) { + return { matched: true, snapshot } + } + + const stateChanged = event.stateStartedAt > card.stateChangedAt + const unseen = stateChanged ? card.startedAt !== 0 : card.unseen + const dotState = event.state + const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) + const nextCard: DashboardCard = { + ...card, + ...(event.agentType ? { agentType: event.agentType } : {}), + ...(event.prompt ? { task: event.prompt, lastUserMessage: event.prompt } : {}), + ...(event.lastAssistantMessage !== undefined + ? { lastAgentMessage: event.lastAssistantMessage || undefined } + : {}), + ...(event.orchestration?.parentPaneKey + ? { parentPaneKey: event.orchestration.parentPaneKey } + : {}), + bucket, + dotState, + unseen, + stateChangedAt: stateChanged ? event.stateStartedAt : card.stateChangedAt, + statusUpdatedAt: event.receivedAt, + finishedAt: dotState === 'done' && stateChanged ? event.stateStartedAt : card.finishedAt, + askSummary: + bucket === 'attention' + ? event.interactivePrompt !== undefined + ? event.interactivePrompt || undefined + : card.askSummary + : undefined, + subagents: patchedSubagents(card, event) + } + const cards = snapshot.cards.slice() + cards[index] = nextCard + return { + matched: true, + snapshot: { ...snapshot, generatedAt: Math.max(snapshot.generatedAt, event.receivedAt), cards } + } +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts b/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts new file mode 100644 index 00000000000..c44843802f8 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapCanvasSize.ts @@ -0,0 +1,35 @@ +import { useEffect, useState, type RefObject } from 'react' + +export type AgentMapCanvasSize = { width: number; height: number } + +export function useAgentMapCanvasSize( + containerRef: RefObject, + onResize: () => void +): AgentMapCanvasSize { + const [size, setSize] = useState({ width: 800, height: 560 }) + + useEffect(() => { + const container = containerRef.current + if (!container || typeof ResizeObserver === 'undefined') { + return + } + const measure = (): void => { + const next = container.getBoundingClientRect() + if (next.width <= 0 || next.height <= 0) { + return + } + onResize() + setSize((current) => + current.width === next.width && current.height === next.height + ? current + : { width: next.width, height: next.height } + ) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(container) + return () => observer.disconnect() + }, [containerRef, onResize]) + + return size +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx b/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx new file mode 100644 index 00000000000..a20c534e4d3 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapContextMenus.tsx @@ -0,0 +1,156 @@ +import { useCallback, useRef, useState } from 'react' +import type { + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import type { AgentMapProjectRing, AgentMapWorktreeRing } from './agent-map-layout' +import { + AgentMapSnapshotWorkspaceMenu, + type AgentMapSnapshotWorkspaceMenuRequest +} from './AgentMapSnapshotWorkspaceMenu' +import { + AgentMapProjectContextMenuLoader, + type AgentMapProjectContextMenuRequest +} from './AgentMapProjectContextMenuLoader' +import { + AgentMapWorkspaceContextMenuLoader, + type AgentMapWorkspaceContextMenuRequest +} from './AgentMapWorkspaceContextMenuLoader' + +type UseAgentMapContextMenusArgs = { + /** True only where the app store lives; the pop-out gets the snapshot menu. */ + enabled: boolean + launchableAgentsByWorktreeId?: Record + onOpenChange?: (open: boolean) => void + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +export function useAgentMapContextMenus({ + enabled, + launchableAgentsByWorktreeId, + onOpenChange, + onSpawnAgent, + onSleepWorkspace +}: UseAgentMapContextMenusArgs): { + contextMenus: React.JSX.Element | null + onOpenProjectContextMenu?: ( + event: React.MouseEvent, + project: AgentMapProjectRing + ) => void + onOpenWorkspaceContextMenu?: ( + event: React.MouseEvent, + worktree: AgentMapWorktreeRing + ) => void +} { + const requestIdRef = useRef(0) + const [workspaceRequest, setWorkspaceRequest] = + useState(null) + const [projectRequest, setProjectRequest] = useState( + null + ) + const [snapshotRequest, setSnapshotRequest] = + useState(null) + const snapshotMenuEnabled = + !enabled && (onSpawnAgent !== undefined || onSleepWorkspace !== undefined) + const openSnapshotWorkspaceMenu = useCallback( + (event: React.MouseEvent, worktree: AgentMapWorktreeRing): void => { + requestIdRef.current += 1 + setSnapshotRequest({ + id: requestIdRef.current, + worktreeId: worktree.worktreeId, + worktreeName: worktree.name, + launchableAgents: launchableAgentsByWorktreeId?.[worktree.worktreeId] ?? [], + clientX: event.clientX, + clientY: event.clientY + }) + }, + [launchableAgentsByWorktreeId] + ) + const openWorkspaceContextMenu = useCallback( + (event: React.MouseEvent, worktree: AgentMapWorktreeRing): void => { + requestIdRef.current += 1 + setProjectRequest(null) + setWorkspaceRequest({ + id: requestIdRef.current, + worktreeId: worktree.worktreeId, + executionHostId: worktree.executionHostId, + clientX: event.clientX, + clientY: event.clientY, + altKey: event.altKey + }) + }, + [] + ) + const openProjectContextMenu = useCallback( + (event: React.MouseEvent, project: AgentMapProjectRing): void => { + requestIdRef.current += 1 + setWorkspaceRequest(null) + setProjectRequest({ + id: requestIdRef.current, + projectId: project.id, + clientX: event.clientX, + clientY: event.clientY + }) + }, + [] + ) + const handleWorkspaceLifecycleComplete = useCallback((): void => { + setWorkspaceRequest(null) + }, []) + const handleProjectOpenChange = useCallback( + (open: boolean): void => { + onOpenChange?.(open) + if (!open) { + setProjectRequest(null) + } + }, + [onOpenChange] + ) + const handleSnapshotOpenChange = useCallback( + (open: boolean): void => { + onOpenChange?.(open) + if (!open) { + setSnapshotRequest(null) + } + }, + [onOpenChange] + ) + const contextMenus = snapshotMenuEnabled ? ( + snapshotRequest ? ( + + ) : null + ) : enabled ? ( + <> + {workspaceRequest ? ( + + ) : null} + {projectRequest ? ( + + ) : null} + + ) : null + + return { + contextMenus, + onOpenProjectContextMenu: enabled ? openProjectContextMenu : undefined, + onOpenWorkspaceContextMenu: enabled + ? openWorkspaceContextMenu + : snapshotMenuEnabled + ? openSnapshotWorkspaceMenu + : undefined + } +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts b/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts new file mode 100644 index 00000000000..2f22f63747e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapSelectedFocus.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef } from 'react' +import type { AgentMapAgentNode } from './agent-map-layout' +import type { AgentMapViewport } from './agent-map-viewport-transition' + +type AgentMapSelectedFocusOptions = { + agents: AgentMapAgentNode[] + selectedPaneKey: string | null + viewportRef: { current: AgentMapViewport } + resolveFocusZoom: () => number + animateViewport: (from: AgentMapViewport, to: AgentMapViewport) => void + stopViewportTransition: () => void +} + +export function useAgentMapSelectedFocus({ + agents, + selectedPaneKey, + viewportRef, + resolveFocusZoom, + animateViewport, + stopViewportTransition +}: AgentMapSelectedFocusOptions): void { + const focusedAgentRef = useRef<{ + paneKey: string + x: number + y: number + zoom: number + } | null>(null) + useEffect(() => { + const selected = agents.find((agent) => agent.card.paneKey === selectedPaneKey) + if (!selectedPaneKey || !selected) { + focusedAgentRef.current = null + stopViewportTransition() + return + } + const targetZoom = resolveFocusZoom() + const focused = focusedAgentRef.current + if ( + focused?.paneKey === selectedPaneKey && + focused.x === selected.x && + focused.y === selected.y && + focused.zoom === targetZoom + ) { + return + } + focusedAgentRef.current = { + paneKey: selectedPaneKey, + x: selected.x, + y: selected.y, + zoom: targetZoom + } + animateViewport(viewportRef.current, { + center: { x: selected.x, y: selected.y }, + zoom: targetZoom + }) + }, [ + agents, + animateViewport, + resolveFocusZoom, + selectedPaneKey, + stopViewportTransition, + viewportRef + ]) +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts b/src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts new file mode 100644 index 00000000000..b98bbf52afb --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapStateFilter.ts @@ -0,0 +1,29 @@ +import { useCallback, useState } from 'react' +import type { AgentMapState } from './agent-map-filter' + +const ALL_AGENT_STATES: AgentMapState[] = ['attention', 'working', 'done', 'idle'] + +/** Map-only state muting. It lives on the board rather than inside the map so + * the shared toolbar filter — the map has no rail of its own — can drive it. */ +export function useAgentMapStateFilter(): { + agentStates: ReadonlySet + toggleAgentState: (state: AgentMapState) => void + resetAgentStates: () => void +} { + const [agentStates, setAgentStates] = useState>( + () => new Set(ALL_AGENT_STATES) + ) + const toggleAgentState = useCallback((state: AgentMapState): void => { + setAgentStates((current) => { + const next = new Set(current) + if (!next.delete(state)) { + next.add(state) + } + return next + }) + }, []) + const resetAgentStates = useCallback((): void => { + setAgentStates(new Set(ALL_AGENT_STATES)) + }, []) + return { agentStates, toggleAgentState, resetAgentStates } +} diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts b/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts new file mode 100644 index 00000000000..91a8e6e1af1 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapViewportTransition.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useRef } from 'react' +import { + startAgentMapViewportTransition, + type AgentMapViewport +} from './agent-map-viewport-transition' + +type AgentMapViewportTransitionOptions = { + durationMs: number + reducedMotion: boolean + onFrame: (viewport: AgentMapViewport) => void +} + +export function useAgentMapViewportTransition({ + durationMs, + reducedMotion, + onFrame +}: AgentMapViewportTransitionOptions): { + animate: (from: AgentMapViewport, to: AgentMapViewport) => void + stop: () => void +} { + const cancelRef = useRef<(() => void) | null>(null) + const stop = useCallback((): void => { + cancelRef.current?.() + cancelRef.current = null + }, []) + const animate = useCallback( + (from: AgentMapViewport, to: AgentMapViewport): void => { + stop() + if (reducedMotion) { + onFrame(to) + return + } + let cancel = (): void => {} + cancel = startAgentMapViewportTransition({ + from, + to, + durationMs, + onFrame, + onComplete: () => { + if (cancelRef.current === cancel) { + cancelRef.current = null + } + } + }) + cancelRef.current = cancel + }, + [durationMs, onFrame, reducedMotion, stop] + ) + useEffect(() => stop, [stop]) + return { animate, stop } +} diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx index 5d6853d848c..9a9c4e45a74 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.test.tsx @@ -4,6 +4,10 @@ import { act } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import type { DashboardCard, DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../../shared/agent-status-types' import { useDashboardSnapshot } from './useDashboardSnapshot' ;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -35,6 +39,9 @@ function snapshot(cards: DashboardCard[]): DashboardSnapshot { } let apply: (next: DashboardSnapshot) => void +let applyStatus: (next: AgentStatusIpcPayload) => void +let applyClear: (next: AgentStatusClearIpcPayload) => void +const requestSnapshot = vi.fn(async () => {}) const startViewTransition = vi.fn((cb: () => void) => { cb() return { @@ -62,7 +69,17 @@ describe('useDashboardSnapshot', () => { apply = cb return () => {} }, - requestSnapshot: vi.fn(async () => {}) + requestSnapshot + }, + agentStatus: { + onSet: (cb: (next: AgentStatusIpcPayload) => void) => { + applyStatus = cb + return () => {} + }, + onClear: (cb: (next: AgentStatusClearIpcPayload) => void) => { + applyClear = cb + return () => {} + } } } ;(document as unknown as { startViewTransition: unknown }).startViewTransition = @@ -140,4 +157,85 @@ describe('useDashboardSnapshot', () => { act(() => apply({ ...snapshot([card({})]), repoIconsByRepoId: {} })) expect(result.current.repoIconsByRepoId).toEqual({}) }) + + it('applies known hook status without requesting a rebuilt snapshot', () => { + const { result } = renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({ statusUpdatedAt: 100 })]))) + requestSnapshot.mockClear() + + act(() => + applyStatus({ + paneKey: 'pk', + state: 'blocked', + prompt: 'Choose', + connectionId: null, + receivedAt: 300, + stateStartedAt: 250 + }) + ) + + expect(requestSnapshot).not.toHaveBeenCalled() + expect(result.current.cards[0]).toMatchObject({ + bucket: 'attention', + task: 'Choose', + statusUpdatedAt: 300 + }) + }) + + it('trailing-debounces an unknown-pane burst into one topology refresh', () => { + vi.useFakeTimers() + renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({})]))) + requestSnapshot.mockClear() + + act(() => { + for (let index = 0; index < 10; index += 1) { + applyStatus({ + paneKey: `unknown-${index}`, + state: 'working', + prompt: '', + connectionId: null, + receivedAt: 300 + index, + stateStartedAt: 250 + index + }) + vi.advanceTimersByTime(40) + } + applyClear({ paneKey: 'pk' }) + vi.advanceTimersByTime(249) + }) + + expect(requestSnapshot).not.toHaveBeenCalled() + act(() => vi.advanceTimersByTime(1)) + expect(requestSnapshot).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) + + it('refreshes within the max wait while unknown events continue', () => { + vi.useFakeTimers() + renderHook(() => useDashboardSnapshot()) + act(() => apply(snapshot([card({})]))) + requestSnapshot.mockClear() + + const unknown = (index: number): void => + applyStatus({ + paneKey: `unknown-${index}`, + state: 'working', + prompt: '', + connectionId: null, + receivedAt: 300 + index, + stateStartedAt: 250 + index + }) + act(() => { + unknown(0) + for (let index = 1; index <= 4; index += 1) { + vi.advanceTimersByTime(200) + unknown(index) + } + }) + expect(requestSnapshot).not.toHaveBeenCalled() + + act(() => vi.advanceTimersByTime(200)) + expect(requestSnapshot).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) }) diff --git a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts index 47704958da8..28e43d54e5b 100644 --- a/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard-popout/useDashboardSnapshot.ts @@ -1,9 +1,14 @@ import { useEffect, useRef, useState } from 'react' import { flushSync } from 'react-dom' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' import { EMPTY_DASHBOARD_SNAPSHOT, type DashboardSnapshot } from '../../../../shared/dashboard-snapshot' +import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch' + +const TOPOLOGY_REFRESH_DEBOUNCE_MS = 250 +const TOPOLOGY_REFRESH_MAX_WAIT_MS = 1_000 /** Which column each card sits in — the only thing a view transition should * animate on. Content-only updates (such as new messages) must not. */ @@ -36,8 +41,58 @@ export function useDashboardSnapshot(): DashboardSnapshot { const [snapshot, setSnapshot] = useState(EMPTY_DASHBOARD_SNAPSHOT) const columnSignatureRef = useRef('') const retainedRepoIconsRef = useRef(undefined) + const snapshotRef = useRef(snapshot) useEffect(() => { + let topologyRefreshTimer: ReturnType | null = null + let topologyRefreshStartedAt: number | null = null + let staleRefreshTimer: ReturnType | null = null + const transientClearWatermarks = new Map() + + const requestTopologyRefresh = (): void => { + if (topologyRefreshTimer) { + clearTimeout(topologyRefreshTimer) + } + const now = Date.now() + topologyRefreshStartedAt ??= now + const remainingMaxWait = Math.max( + 0, + TOPOLOGY_REFRESH_MAX_WAIT_MS - (now - topologyRefreshStartedAt) + ) + topologyRefreshTimer = setTimeout( + () => { + topologyRefreshTimer = null + topologyRefreshStartedAt = null + void window.api.dashboard.requestSnapshot() + }, + Math.min(TOPOLOGY_REFRESH_DEBOUNCE_MS, remainingMaxWait) + ) + } + + const scheduleStaleRefresh = (next: DashboardSnapshot): void => { + if (staleRefreshTimer) { + clearTimeout(staleRefreshTimer) + staleRefreshTimer = null + } + let nextDeadline = Number.POSITIVE_INFINITY + for (const card of next.cards) { + if ( + card.statusUpdatedAt !== undefined && + (card.dotState === 'working' || + card.dotState === 'blocked' || + card.dotState === 'waiting') + ) { + nextDeadline = Math.min(nextDeadline, card.statusUpdatedAt + AGENT_STATUS_STALE_AFTER_MS) + } + } + if (Number.isFinite(nextDeadline)) { + staleRefreshTimer = setTimeout( + requestTopologyRefresh, + Math.max(0, nextDeadline - Date.now() + 1) + ) + } + } + const apply = (incoming: DashboardSnapshot): void => { // The bridge omits repoIconsByRepoId on throttled republishes when it has // not changed, rather than re-sending data URLs 4x/sec. Retain the last @@ -50,6 +105,8 @@ export function useDashboardSnapshot(): DashboardSnapshot { if (next.repoIconsByRepoId !== undefined) { retainedRepoIconsRef.current = next.repoIconsByRepoId } + snapshotRef.current = next + scheduleStaleRefresh(next) const nextSignature = columnSignature(next) const layoutChanged = nextSignature !== columnSignatureRef.current columnSignatureRef.current = nextSignature @@ -72,8 +129,45 @@ export function useDashboardSnapshot(): DashboardSnapshot { } const unsubscribe = window.api.dashboard.onSnapshot(apply) + const unsubscribeStatus = window.api.agentStatus.onSet((event) => { + if ( + typeof event.connectionId === 'string' && + event.receivedAt <= (transientClearWatermarks.get(event.connectionId) ?? -1) + ) { + return + } + const result = patchDashboardSnapshotFromAgentStatus(snapshotRef.current, event) + if (!result.matched) { + if (snapshotRef.current.generatedAt !== 0) { + requestTopologyRefresh() + } + return + } + if (result.snapshot !== snapshotRef.current) { + apply(result.snapshot) + } + }) + const unsubscribeClear = window.api.agentStatus.onClear((event) => { + if ('transient' in event && event.transient) { + transientClearWatermarks.set( + event.connectionId, + Math.max(transientClearWatermarks.get(event.connectionId) ?? -1, event.clearedAt) + ) + } + requestTopologyRefresh() + }) void window.api.dashboard.requestSnapshot() - return unsubscribe + return () => { + unsubscribe() + unsubscribeStatus() + unsubscribeClear() + if (topologyRefreshTimer) { + clearTimeout(topologyRefreshTimer) + } + if (staleRefreshTimer) { + clearTimeout(staleRefreshTimer) + } + } }, []) return snapshot diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx new file mode 100644 index 00000000000..ea1cdb80063 --- /dev/null +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' + +const mocks = vi.hoisted(() => ({ + useLiveDashboardSnapshot: vi.fn(() => ({ generatedAt: 1, cards: [] })), + blockingOverlay: false, + boardProps: null as Record | null, + activateTabAndFocusPane: vi.fn() +})) + +vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ + activateTabAndFocusPane: mocks.activateTabAndFocusPane +})) + +vi.mock('./useLiveDashboardSnapshot', () => ({ + useLiveDashboardSnapshot: mocks.useLiveDashboardSnapshot +})) + +vi.mock('../dashboard-popout/AgentKanbanBoard', () => ({ + AgentKanbanBoard: (props: Record) => { + mocks.boardProps = props + return mocks.blockingOverlay ?
: null + } +})) + +vi.mock('./AgentDashboardSettingsMenu', () => ({ + AgentDashboardSettingsMenu: () => null +})) + +vi.mock('../sidebar/use-workspace-kanban-outside-dismiss', () => ({ + isWorkspaceBoardKeepOpenTarget: () => false, + useWorkspaceKanbanOutsideDismiss: () => undefined +})) + +import { AgentDashboardDrawer } from './AgentDashboardDrawer' + +const initialState = useAppStore.getInitialState() + +beforeEach(() => { + useAppStore.setState( + { + agentDashboardDrawerOpen: false, + sidebarOpen: true, + sidebarWidth: 320 + }, + false + ) + mocks.useLiveDashboardSnapshot.mockClear() + mocks.blockingOverlay = false + mocks.boardProps = null + ;(window as unknown as { api: unknown }).api = { + dashboard: { openPopout: vi.fn().mockResolvedValue(undefined) } + } +}) + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) +}) + +describe('AgentDashboardDrawer', () => { + it('derives no dashboard snapshot while closed', () => { + render() + + expect(mocks.useLiveDashboardSnapshot).not.toHaveBeenCalled() + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + expect(mocks.useLiveDashboardSnapshot).toHaveBeenCalledTimes(1) + }) + + it('leaves Escape to an open terminal panel before dismissing the drawer', () => { + mocks.blockingOverlay = true + const view = render() + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(true) + + mocks.blockingOverlay = false + view.rerender() + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false) + }) + + it('hands map rendering to the dedicated popout', () => { + const openPopout = vi.mocked(window.api.dashboard.openPopout) + render() + expect(mocks.boardProps).toBeNull() + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + expect(mocks.boardProps?.initialView).toBe('board') + expect(mocks.boardProps?.workspaceContextMenusEnabled).toBeUndefined() + const onOpenMap = mocks.boardProps?.onOpenMap + expect(onOpenMap).toBeTypeOf('function') + + act(() => { + ;(onOpenMap as () => void)() + }) + + expect(openPopout).toHaveBeenCalledWith('map') + expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false) + }) + + it('reveals a colliding worktree on the card execution host', () => { + const setActiveWorktree = vi.spyOn(useAppStore.getState(), 'setActiveWorktree') + render() + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + const onRevealAgent = mocks.boardProps?.onRevealAgent + expect(onRevealAgent).toBeTypeOf('function') + + act(() => { + ;( + onRevealAgent as (args: { + repoId: string + worktreeId: string + executionHostId?: string + tabId: string + leafId: string | null + }) => void + )({ + repoId: 'repo-1', + worktreeId: 'shared-worktree', + executionHostId: 'runtime:env-1', + tabId: 'tab-1', + leafId: 'leaf-1' + }) + }) + + expect(setActiveWorktree).toHaveBeenCalledWith('shared-worktree', 'runtime:env-1') + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith('tab-1', 'leaf-1', { + flashFocusedPane: true + }) + }) +}) diff --git a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx index 927c7930346..e9e0efcc13d 100644 --- a/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx +++ b/src/renderer/src/components/dashboard/AgentDashboardDrawer.tsx @@ -13,6 +13,7 @@ import { WORKSPACE_TOP_CHROME_HEIGHT } from '../sidebar/workspace-chrome-metrics' import { AgentDashboardSettingsMenu } from './AgentDashboardSettingsMenu' +import { launchDashboardAgent } from './launch-dashboard-agent' import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot' import { translate } from '@/i18n/i18n' @@ -47,7 +48,7 @@ function AgentDashboardDrawerBody({ }, []) const handleRevealAgent = useCallback( (args: AgentRevealArgs) => { - useAppStore.getState().setActiveWorktree(args.worktreeId) + useAppStore.getState().setActiveWorktree(args.worktreeId, args.executionHostId) activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) onClose() }, @@ -61,15 +62,23 @@ function AgentDashboardDrawerBody({ void window.api.dashboard.openPopout?.() }, [onClose]) + const handleOpenMap = useCallback(() => { + onClose() + void window.api.dashboard.openPopout?.('map') + }, [onClose]) + return ( { + it('keeps all dashboard feature modules out of the disabled app and sidebar path', () => { + const app = source('App.tsx') + const sidebar = source('components/sidebar/index.tsx') + const nav = source('components/sidebar/SidebarNav.tsx') + + expect(app).not.toMatch(/from ['"].*DashboardPopoutBridge['"]/) + expect(app).toContain("import('./components/dashboard/DashboardPopoutBridge')") + expect(sidebar).not.toMatch(/from ['"].*AgentDashboard(?:Drawer|SidebarHost)['"]/) + expect(sidebar).toContain("import('./AgentDashboardSidebarHost')") + expect(nav).not.toContain('useAgentBucketCounts') + expect(nav).not.toContain('shared/dashboard-snapshot') + expect(nav).toContain("import('./AgentDashboardSidebarEntry')") + }) + + it('keeps map computation out of the main-renderer drawer', () => { + const board = source('components/dashboard-popout/AgentKanbanBoard.tsx') + const drawer = source('components/dashboard/AgentDashboardDrawer.tsx') + + expect(board).toContain("import('./AgentDashboardMapView')") + expect(board).not.toMatch(/from ['"].\/(?:AgentMap|useAgentMap|agent-map-)/) + expect(drawer).toContain('initialView="board"') + expect(drawer).toContain("openPopout?.('map')") + expect(drawer).toContain('onOpenMap={handleOpenMap}') + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-row-lineage.ts b/src/renderer/src/components/dashboard/agent-row-lineage.ts index 38efc312a67..9010c8e8ac1 100644 --- a/src/renderer/src/components/dashboard/agent-row-lineage.ts +++ b/src/renderer/src/components/dashboard/agent-row-lineage.ts @@ -3,6 +3,7 @@ import { buildAgentRowLineageTree } from './agent-row-lineage-model' export type AgentRowLineagePresentation = { depth: 0 | 1 + parentPaneKey?: string isFirstSibling: boolean isLastSibling: boolean childCount: number @@ -52,6 +53,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR // visible parent, but visual depth remains capped so dense sidebar // rows don't lose too much prompt width. depth: 1, + parentPaneKey: row.paneKey, isFirstSibling: index === 0, isLastSibling: index === children.length - 1, childCount: 0 @@ -69,3 +71,11 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR return ordered } + +export function dashboardCardParentPaneKey(row: DashboardAgentRowWithLineage): string | undefined { + const directParentPaneKey = row.entry.orchestration?.parentPaneKey + return ( + row.lineage.parentPaneKey ?? + (directParentPaneKey === row.paneKey ? undefined : directParentPaneKey) + ) +} diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts new file mode 100644 index 00000000000..f5db7045970 --- /dev/null +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-folder-workspace.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import type { FolderWorkspace, ProjectGroup, TerminalTab } from '../../../../shared/types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' + +const NOW = 2_000_000_000 +const WORKSPACE_ID = folderWorkspaceKey('folder-1') +const TAB_ID = 'folder-tab' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) + +function folderWorkspace(): FolderWorkspace { + return { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Docs workspace', + folderPath: '/workspace/docs', + connectionId: 'ssh-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + createdAt: NOW, + updatedAt: NOW + } +} + +function projectGroup(): ProjectGroup { + return { + id: 'group-1', + name: 'Documentation', + parentPath: '/workspace', + connectionId: 'ssh-1', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: NOW, + updatedAt: NOW + } +} + +function tab(): TerminalTab { + return { + id: TAB_ID, + ptyId: 'pty-folder', + worktreeId: WORKSPACE_ID, + title: 'codex', + customTitle: 'Docs reviewer', + color: null, + sortOrder: 0, + createdAt: NOW + } +} + +function entry(): AgentStatusEntry { + return { + paneKey: PANE_KEY, + state: 'working', + prompt: 'Review the docs', + updatedAt: NOW, + stateStartedAt: NOW - 60_000, + stateHistory: [], + agentType: 'codex', + tabId: TAB_ID, + worktreeId: WORKSPACE_ID + } +} + +function state(): DashboardSnapshotState { + return { + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [folderWorkspace()], + projectGroups: [projectGroup()], + tabsByWorktree: { [WORKSPACE_ID]: [tab()] }, + agentStatusByPaneKey: { [PANE_KEY]: entry() }, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-folder' } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty-folder'] }, + runtimePaneTitlesByTabId: {}, + acknowledgedAgentsByPaneKey: {}, + settings: null + } as unknown as DashboardSnapshotState +} + +describe('buildDashboardSnapshot folder workspaces', () => { + it('places folder-workspace agents in their real project group without git assumptions', () => { + const snapshot = buildDashboardSnapshot(state(), NOW) + + expect(snapshot.cards).toHaveLength(1) + expect(snapshot.cards[0]).toMatchObject({ + paneKey: PANE_KEY, + repoId: 'folder-workspace:group-1', + repoName: 'Documentation', + worktreeId: WORKSPACE_ID, + worktreeName: 'Docs workspace', + workspaceKind: 'folder', + hostKind: 'ssh', + executionHostId: 'ssh:ssh-1' + }) + expect(snapshot.filterOptions?.projects).toEqual([ + { id: 'folder-workspace:group-1', label: 'Documentation' } + ]) + expect(snapshot.workspaces).toEqual([ + expect.objectContaining({ + repoId: 'folder-workspace:group-1', + worktreeId: WORKSPACE_ID, + repoName: 'Documentation', + worktreeName: 'Docs workspace', + workspaceKind: 'folder', + hostKind: 'ssh', + executionHostId: 'ssh:ssh-1' + }) + ]) + }) + + it('classifies a folder workspace from its own runtime host stamp', () => { + const runtimeState = state() + runtimeState.folderWorkspaces = [ + { ...folderWorkspace(), connectionId: null, executionHostId: 'runtime:environment-1' } + ] + runtimeState.projectGroups = [{ ...projectGroup(), connectionId: null }] + + const snapshot = buildDashboardSnapshot(runtimeState, NOW) + + expect(snapshot.cards[0].hostKind).toBe('remote') + expect(snapshot.cards[0].executionHostId).toBe('runtime:environment-1') + }) +}) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts index aa3534eac4d..90630a3d924 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' import { AGENT_STATUS_STALE_AFTER_MS, @@ -9,12 +9,47 @@ import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapsho import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab, Worktree } from '../../../../shared/types' import { selectRuntimeAgentOrchestrationBatch } from '../sidebar/worktree-agent-orchestration-batch' +import type * as DashboardSnapshotWorkspacesModule from './dashboard-snapshot-workspaces' +import type * as AgentRowLineageModule from './agent-row-lineage' + +const mapMetadataCalls = vi.hoisted(() => ({ + hostKind: vi.fn(), + parentPaneKey: vi.fn() +})) + +vi.mock('./dashboard-snapshot-workspaces', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + dashboardCardMapWorkspaceMetadata: ( + ...args: Parameters + ) => { + mapMetadataCalls.hostKind() + return actual.dashboardCardMapWorkspaceMetadata(...args) + } + } +}) + +vi.mock('./agent-row-lineage', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + dashboardCardParentPaneKey: (...args: Parameters) => { + mapMetadataCalls.parentPaneKey() + return actual.dashboardCardParentPaneKey(...args) + } + } +}) const NOW = 1_000_000_000 const TAB_ID = 'tab1' const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const CHILD_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const GRANDCHILD_LEAF_ID = '44444444-4444-4444-8444-444444444444' const GONE_LEAF_ID = '22222222-2222-4222-8222-222222222222' const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const CHILD_PANE_KEY = makePaneKey(TAB_ID, CHILD_LEAF_ID) +const GRANDCHILD_PANE_KEY = makePaneKey(TAB_ID, GRANDCHILD_LEAF_ID) function entry(overrides: Partial): AgentStatusEntry { return { @@ -110,6 +145,23 @@ describe('buildDashboardSnapshot', () => { ) expect(snapshot.cards).toEqual([]) + expect(snapshot.workspaces).toEqual([ + expect.objectContaining({ + repoId: 'r1', + worktreeId: 'w1', + repoName: 'Repo One', + worktreeName: 'wt-one', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree' + }), + expect.objectContaining({ + repoId: 'r2', + worktreeId: 'w2', + repoName: 'Repo Two', + worktreeName: 'wt-two' + }) + ]) expect(snapshot.filterOptions).toEqual({ projects: [ { id: 'r1', label: 'Repo One' }, @@ -147,6 +199,124 @@ describe('buildDashboardSnapshot', () => { expect(card.unseen).toBe(true) }) + it('publishes terminal-backed orchestrated workers under their direct parent', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ paneKey: PANE_KEY }), + [CHILD_PANE_KEY]: entry({ paneKey: CHILD_PANE_KEY }), + [GRANDCHILD_PANE_KEY]: entry({ paneKey: GRANDCHILD_PANE_KEY }) + }, + runtimeAgentOrchestrationByPaneKey: { + [CHILD_PANE_KEY]: { + taskId: 'worker-task', + dispatchId: 'worker-dispatch', + parentPaneKey: PANE_KEY + }, + [GRANDCHILD_PANE_KEY]: { + taskId: 'nested-worker-task', + dispatchId: 'nested-worker-dispatch', + parentPaneKey: CHILD_PANE_KEY + } + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_ID]: 'pty1', + [CHILD_LEAF_ID]: 'pty-child', + [GRANDCHILD_LEAF_ID]: 'pty-grandchild' + } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty1', 'pty-child', 'pty-grandchild'] } + }), + NOW + ) + + expect(snapshot.cards.map((card) => card.paneKey)).toEqual([ + PANE_KEY, + CHILD_PANE_KEY, + GRANDCHILD_PANE_KEY + ]) + expect(snapshot.cards[1]).toMatchObject({ + paneKey: CHILD_PANE_KEY, + parentPaneKey: PANE_KEY, + ptyId: 'pty-child' + }) + expect(snapshot.cards[2]).toMatchObject({ + paneKey: GRANDCHILD_PANE_KEY, + parentPaneKey: CHILD_PANE_KEY, + ptyId: 'pty-grandchild' + }) + }) + + it('preserves explicit lineage when a child runs in another worktree', () => { + const childTabId = 'child-tab' + const childPaneKey = makePaneKey(childTabId, CHILD_LEAF_ID) + const snapshot = buildDashboardSnapshot( + baseState({ + worktreesByRepo: { r1: [worktree(), worktree('w2', 'wt-two')] }, + tabsByWorktree: { w1: [tab()], w2: [tab(childTabId, 'w2')] }, + agentStatusByPaneKey: { + [PANE_KEY]: entry({ paneKey: PANE_KEY }), + [childPaneKey]: entry({ + paneKey: childPaneKey, + tabId: childTabId, + worktreeId: 'w2', + orchestration: { + taskId: 'child-task', + dispatchId: 'child-dispatch', + parentPaneKey: PANE_KEY + } + }) + }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty1' } + }, + [childTabId]: { + root: { type: 'leaf', leafId: CHILD_LEAF_ID }, + activeLeafId: CHILD_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [CHILD_LEAF_ID]: 'pty-child' } + } + }, + ptyIdsByTabId: { [TAB_ID]: ['pty1'], [childTabId]: ['pty-child'] } + }), + NOW + ) + + expect(snapshot.cards.find((item) => item.paneKey === childPaneKey)).toMatchObject({ + worktreeName: 'wt-two', + parentPaneKey: PANE_KEY + }) + }) + + it('does not publish malformed self-parent lineage', () => { + const snapshot = buildDashboardSnapshot( + baseState({ + agentStatusByPaneKey: { + [PANE_KEY]: entry({ + orchestration: { + taskId: 'self-task', + dispatchId: 'self-dispatch', + parentPaneKey: PANE_KEY + } + }) + } + }), + NOW + ) + + expect(snapshot.cards[0].parentPaneKey).toBeUndefined() + }) + it('carries the tab conversation name and drops status-only titles', () => { const named = buildDashboardSnapshot( baseState({ @@ -256,16 +426,18 @@ describe('buildDashboardSnapshot', () => { expect(snapshot.cards[0].ptyId).toBeNull() }) - it('mutes unseen once the agent is acknowledged after its state change', () => { + it('moves an acknowledged completion to idle while retaining its raw done state', () => { const snapshot = buildDashboardSnapshot( baseState({ - agentStatusByPaneKey: { [PANE_KEY]: entry({}) }, + agentStatusByPaneKey: { [PANE_KEY]: entry({ state: 'done' }) }, acknowledgedAgentsByPaneKey: { [PANE_KEY]: NOW - 1000 } }), NOW ) // ack (NOW-1000) is after stateStartedAt (NOW-5000) → seen. expect(snapshot.cards[0].unseen).toBe(false) + expect(snapshot.cards[0].dotState).toBe('done') + expect(snapshot.cards[0].bucket).toBe('idle') }) it('does not mark title-derived rows unseen from synthetic timestamps', () => { @@ -346,7 +518,13 @@ describe('buildDashboardSnapshot', () => { { id: 'reviewing', label: 'Reviewing', color: 'emerald' } ], worktreesByRepo: { - r1: [{ ...worktree(), workspaceStatus: 'reviewing' }] + r1: [ + { + ...worktree(), + workspaceStatus: 'reviewing', + parentWorktreeId: 'parent-worktree' + } as Worktree & { parentWorktreeId: string } + ] }, agentStatusByPaneKey: { [PANE_KEY]: entry({ @@ -369,12 +547,16 @@ describe('buildDashboardSnapshot', () => { workspaceStatusId: 'reviewing', workspaceStatusLabel: 'Reviewing', workspaceStatusColor: 'emerald', + parentWorktreeId: 'parent-worktree', subagents: [{ name: 'Review loop', dotState: 'working' }] }) }) it('skips card-only context for count snapshots', () => { + mapMetadataCalls.hostKind.mockClear() + mapMetadataCalls.parentPaneKey.mockClear() let linkedReviewReads = 0 + let hostIdentityReads = 0 const countWorktree = worktree() Object.defineProperty(countWorktree, 'linkedPR', { enumerable: true, @@ -383,8 +565,20 @@ describe('buildDashboardSnapshot', () => { return null } }) + const countRepo = { + id: 'r1', + path: '/r1', + displayName: 'Repo One', + badgeColor: '#000', + addedAt: 0, + get connectionId() { + hostIdentityReads += 1 + return null + } + } const snapshot = buildDashboardSnapshot( baseState({ + repos: [countRepo], worktreesByRepo: { r1: [countWorktree] }, agentStatusByPaneKey: { [PANE_KEY]: entry({}) } }), @@ -394,11 +588,19 @@ describe('buildDashboardSnapshot', () => { expect(snapshot.cards[0].workspaceStatusId).toBeUndefined() expect(snapshot.cards[0].subagents).toBeUndefined() + expect(snapshot.cards[0].parentPaneKey).toBeUndefined() + expect(snapshot.cards[0].parentWorktreeId).toBeUndefined() + expect(snapshot.cards[0].hostKind).toBeUndefined() + expect(snapshot.cards[0].workspaceKind).toBeUndefined() // Why: the card has a live pty, so only the count-path gate keeps the // host-input resolution off the sidebar's per-status-tick rebuild. expect(snapshot.cards[0].ptyId).toBe('pty1') expect(snapshot.cards[0].terminalInput).toBeUndefined() + expect(snapshot.workspaces).toBeUndefined() expect(linkedReviewReads).toBe(0) + expect(hostIdentityReads).toBe(0) + expect(mapMetadataCalls.hostKind).not.toHaveBeenCalled() + expect(mapMetadataCalls.parentPaneKey).not.toHaveBeenCalled() }) it("resolves a live pty's host-input profile for card snapshots", () => { diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index c7c3c9711cc..808ffba0236 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -1,25 +1,23 @@ import type { AppState } from '@/store/types' import { - DASHBOARD_MAX_LABEL_LENGTH, - type DashboardBucket, + DASHBOARD_MAX_MAP_WORKSPACES, + dashboardCardDisplayState, type DashboardCard, type DashboardCardDotState, type DashboardCardSubagent, - type DashboardSnapshot + type DashboardSnapshot, + type DashboardWorkspace } from '../../../../shared/dashboard-snapshot' import type { RepoIcon } from '../../../../shared/repo-icon' -import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' import { parsePaneKey } from '../../../../shared/stable-pane-id' import { resolveDashboardCardTerminalInput, type DashboardCardTerminalInputState } from './dashboard-card-terminal-input' import { readDashboardClientHost } from './dashboard-client-host' -import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' -import { applyAgentRowLineage } from './agent-row-lineage' +import { applyAgentRowLineage, dashboardCardParentPaneKey } from './agent-row-lineage' import { lastEnteredDoneAt } from './agent-finished-timestamp' -import type { DashboardAgentRow } from './useDashboardData' import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows' import { selectLiveAgentStatusEntriesForWorktree, @@ -41,6 +39,23 @@ import { resolveDashboardCardContext, type DashboardCardContextState } from './dashboard-card-context' +import { + dashboardCardMapWorkspaceMetadata, + collectActiveDashboardWorkspaces +} from './dashboard-snapshot-workspaces' +import { + boundedLabel, + boundedLabelOrUndefined, + nonEmpty, + rowConversationName, + rowTask +} from './dashboard-card-labels' +import { + buildDashboardWorktreeLaunchOptions, + type DashboardLaunchDetectionState +} from './dashboard-worktree-launch-options' +import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options' +import { dashboardBucketForDotState } from './dashboard-card-bucket' /** The store slices the snapshot builder reads. Kept as a Pick so unit tests * can pass a partial store without constructing the whole AppState. */ @@ -60,68 +75,14 @@ export type DashboardSnapshotState = Pick< | 'settings' > & DashboardCardContextState & - Partial - -function bucketForState(state: DashboardAgentRow['state']): DashboardBucket { - switch (state) { - case 'working': - return 'working' - case 'done': - return 'done' - case 'idle': - return 'idle' - // blocked | waiting — the agent needs the user. - case 'blocked': - case 'waiting': - return 'attention' - } -} - -function rowTask(row: DashboardAgentRow): string { - return (row.entry.orchestration?.taskTitle ?? '').trim() || (row.entry.prompt ?? '').trim() -} - -function nonEmpty(value: string | undefined): string | undefined { - const trimmed = (value ?? '').trim() - return trimmed.length > 0 ? trimmed : undefined -} - -/** Why: these labels come from unbounded sources (`terminal rename`, OSC titles, - * display names). Over the validator's bound the card would be dropped. */ -function boundedLabel(value: string): string { - return value.length > DASHBOARD_MAX_LABEL_LENGTH - ? value.slice(0, DASHBOARD_MAX_LABEL_LENGTH) - : value -} - -function boundedLabelOrUndefined(value: string | undefined): string | undefined { - return value === undefined ? undefined : boundedLabel(value) -} - -/** Mirrors useAgentRowConversationName so the board and the sidebar label the - * same agent with the same name. */ -function rowConversationName( - row: DashboardAgentRow, - generatedTitlesEnabled: boolean -): string | undefined { - const parentPaneKey = row.entry.orchestration?.parentPaneKey - // Why: a child row rendered on its parent's tab does not own that tab's name. - if ( - row.lineage?.depth === 1 && - parentPaneKey !== undefined && - parsePaneKey(parentPaneKey)?.tabId === row.tab.id - ) { - return undefined - } - return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined -} + Partial /** * Derive the serializable dashboard snapshot from the live renderer store. * Reuses the exact per-worktree row machinery the sidebar uses * (buildWorktreeAgentRows + the indexed selectors), then flattens every - * worktree's rows into presentational cards. Subagent/child rows are excluded - * from the board (out of scope for v1). + * worktree's rows into presentational cards. Provider subagents without their + * own terminal stay folded into their spawning card. */ export function buildDashboardSnapshot( state: DashboardSnapshotState, @@ -129,40 +90,18 @@ export function buildDashboardSnapshot( options: { includeCardDetails?: boolean; includeFilterOptions?: boolean } = {} ): DashboardSnapshot { const cards: DashboardCard[] = [] + const workspaces: DashboardWorkspace[] | undefined = + options.includeCardDetails === false ? undefined : [] const clientHost = readDashboardClientHost() const repoIconsByRepoId: Record = {} const includeCardDetails = options.includeCardDetails !== false const generatedTitlesEnabled = state.settings?.tabAutoGenerateTitle === true - const activeWorktrees: { - repo: AppState['repos'][number] - worktree: AppState['worktreesByRepo'][string][number] - }[] = [] - - for (const repo of state.repos ?? []) { - for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) { - if (!worktree.isArchived) { - activeWorktrees.push({ repo, worktree }) - } - } - } + const showIdle = state.settings?.experimentalAgentDashboardShowIdle === true + const activeWorktrees = collectActiveDashboardWorkspaces(state, includeCardDetails) const filterOptions = options.includeFilterOptions === false ? undefined - : { - // Why: filterOptions is snapshot-level, so an over-long project label - // costs the WHOLE board, not one card. Bound it at the producer. - projects: [...new Map(activeWorktrees.map(({ repo }) => [repo.id, repo])).values()].map( - (repo) => ({ id: repo.id, label: boundedLabel(repo.displayName) }) - ), - workspaceStatuses: (state.workspaceStatuses && state.workspaceStatuses.length > 0 - ? state.workspaceStatuses - : DEFAULT_WORKSPACE_STATUSES - ).map((status) => ({ - id: status.id, - label: status.label, - color: status.color - })) - } + : buildDashboardSnapshotFilterOptions(state, activeWorktrees) let singletonOrchestration: ReturnType | null = null let orchestrationByWorktree: ReturnType | null = null @@ -181,8 +120,10 @@ export function buildDashboardSnapshot( } } - for (const { repo, worktree } of activeWorktrees) { + for (const workspace of activeWorktrees) { + const { repo, worktree } = workspace const worktreeId = worktree.id + const parentWorktreeId = worktree.parentWorktreeId const liveEntries = selectLiveAgentStatusEntriesForWorktree(state, worktreeId) const migrationUnsupported = selectMigrationUnsupportedEntriesForWorktree(state, worktreeId) const entries = @@ -243,6 +184,21 @@ export function buildDashboardSnapshot( const context = includeCardDetails ? resolveDashboardCardContext(state, repo, worktree) : undefined + if (workspaces && workspaces.length < DASHBOARD_MAX_MAP_WORKSPACES) { + workspaces.push({ + repoId: workspace.projectId, + worktreeId, + repoName: boundedLabel(workspace.projectName), + worktreeName: boundedLabel(worktree.displayName), + ...(parentWorktreeId ? { parentWorktreeId } : {}), + ...dashboardCardMapWorkspaceMetadata(workspace, null, undefined, clientHost.platform), + workspaceStatusId: context?.workspaceStatus.id, + workspaceStatusLabel: context?.workspaceStatus.label, + workspaceStatusColor: context?.workspaceStatus.color, + hasReview: context?.hasReview, + review: context?.review + }) + } for (const row of rows) { // Child rows have no pane of their own; the board lists top-level agents. @@ -268,7 +224,10 @@ export function buildDashboardSnapshot( ? layoutPtyId : null const dotState = row.state as DashboardCardDotState - const bucket = bucketForState(row.state) + const unseen = + !isTitleDerived && + (state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt + const bucket = dashboardBucketForDotState(dashboardCardDisplayState({ dotState, unseen })) // Why: only a live pty can open a preview terminal, and only a // card-rendering caller can open one — the sidebar's bucket counts must // not pay host resolution on every agent-status tick. @@ -286,8 +245,9 @@ export function buildDashboardSnapshot( osRelease: clientHost.osRelease }) : null + const finishedAt = lastEnteredDoneAt(row) // Only repos that actually contribute a card ship their icon. - repoIconsByRepoId[repo.id] = repo.repoIcon ?? null + repoIconsByRepoId[workspace.projectId] = workspace.repoIcon cards.push({ paneKey: row.paneKey, @@ -296,12 +256,24 @@ export function buildDashboardSnapshot( bucket, dotState, task: isTitleDerived ? '' : rowTask(row), - repoId: repo.id, + repoId: workspace.projectId, worktreeId, tabId, leafId, - repoName: boundedLabel(repo.displayName), + repoName: boundedLabel(workspace.projectName), worktreeName: boundedLabel(worktree.displayName), + ...(includeCardDetails + ? { + parentPaneKey: dashboardCardParentPaneKey(row), + ...(parentWorktreeId ? { parentWorktreeId } : {}), + ...dashboardCardMapWorkspaceMetadata( + workspace, + ptyId, + terminalInput ?? undefined, + clientHost.platform + ) + } + : {}), workspaceStatusId: context?.workspaceStatus.id, workspaceStatusLabel: context?.workspaceStatus.label, workspaceStatusColor: context?.workspaceStatus.color, @@ -311,13 +283,12 @@ export function buildDashboardSnapshot( lastUserMessage: isTitleDerived ? undefined : nonEmpty(row.entry.prompt), lastAgentMessage: isTitleDerived ? undefined : nonEmpty(row.entry.lastAssistantMessage), startedAt: row.startedAt, - finishedAt: lastEnteredDoneAt(row), + finishedAt, stateChangedAt: row.entry.stateStartedAt || row.startedAt, + statusUpdatedAt: row.entry.updatedAt, // Same derivation as WorktreeCardAgents' unvisitedByPaneKey, so the // board and the sidebar bold/mute the same agents at the same time. - unseen: - !isTitleDerived && - (state.acknowledgedAgentsByPaneKey?.[row.paneKey] ?? 0) < row.entry.stateStartedAt, + unseen, askSummary: bucket === 'attention' ? (row.entry.interactivePrompt ?? undefined) : undefined, conversationName: boundedLabelOrUndefined(rowConversationName(row, generatedTitlesEnabled)), ...(terminalInput ? { terminalInput } : {}) @@ -328,8 +299,20 @@ export function buildDashboardSnapshot( return { generatedAt: now, cards, - showIdle: state.settings?.experimentalAgentDashboardShowIdle === true, + ...(workspaces ? { workspaces } : {}), + showIdle, filterOptions, + // Only the dashboard surfaces offer a launcher; the count-only rebuild that + // feeds the sidebar must not pay for host-detection lookups. + ...(includeCardDetails + ? { + launchableAgentsByWorktreeId: buildDashboardWorktreeLaunchOptions( + state, + cards, + workspaces + ) + } + : {}), repoIconsByRepoId } } diff --git a/src/renderer/src/components/dashboard/dashboard-card-bucket.ts b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts new file mode 100644 index 00000000000..a81129a227c --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-card-bucket.ts @@ -0,0 +1,15 @@ +import type { DashboardBucket, DashboardCardDotState } from '../../../../shared/dashboard-snapshot' + +export function dashboardBucketForDotState(state: DashboardCardDotState): DashboardBucket { + switch (state) { + case 'working': + return 'working' + case 'done': + return 'done' + case 'idle': + return 'idle' + case 'blocked': + case 'waiting': + return 'attention' + } +} diff --git a/src/renderer/src/components/dashboard/dashboard-card-context.ts b/src/renderer/src/components/dashboard/dashboard-card-context.ts index 45b17b50d53..f376a52163c 100644 --- a/src/renderer/src/components/dashboard/dashboard-card-context.ts +++ b/src/renderer/src/components/dashboard/dashboard-card-context.ts @@ -37,10 +37,10 @@ function hasLinkedReview(worktree: Worktree): boolean { function resolveReview( state: DashboardCardContextState, - repo: Repo, + repo: Repo | null, worktree: Worktree ): DashboardCardReview | undefined { - if (!state.hostedReviewCache || !state.prCache || repo.kind === 'folder') { + if (!repo || !state.hostedReviewCache || !state.prCache || repo.kind === 'folder') { return undefined } const branch = branchName(worktree.branch) @@ -77,7 +77,7 @@ function resolveReview( export function resolveDashboardCardContext( state: DashboardCardContextState, - repo: Repo, + repo: Repo | null, worktree: Worktree ): DashboardCardContext { const statuses = diff --git a/src/renderer/src/components/dashboard/dashboard-card-labels.ts b/src/renderer/src/components/dashboard/dashboard-card-labels.ts new file mode 100644 index 00000000000..df4f5f99d34 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-card-labels.ts @@ -0,0 +1,43 @@ +import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name' +import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapshot' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { DashboardAgentRow } from './useDashboardData' + +export function rowTask(row: DashboardAgentRow): string { + return (row.entry.orchestration?.taskTitle ?? '').trim() || (row.entry.prompt ?? '').trim() +} + +export function nonEmpty(value: string | undefined): string | undefined { + const trimmed = (value ?? '').trim() + return trimmed.length > 0 ? trimmed : undefined +} + +/** Why: these labels come from unbounded sources (`terminal rename`, OSC titles, + * display names). Over the validator's bound the card would be dropped. */ +export function boundedLabel(value: string): string { + return value.length > DASHBOARD_MAX_LABEL_LENGTH + ? value.slice(0, DASHBOARD_MAX_LABEL_LENGTH) + : value +} + +export function boundedLabelOrUndefined(value: string | undefined): string | undefined { + return value === undefined ? undefined : boundedLabel(value) +} + +/** Mirrors useAgentRowConversationName so the board and the sidebar label the + * same agent with the same name. */ +export function rowConversationName( + row: DashboardAgentRow, + generatedTitlesEnabled: boolean +): string | undefined { + const parentPaneKey = row.entry.orchestration?.parentPaneKey + // Why: a child row rendered on its parent's tab does not own that tab's name. + if ( + row.lineage?.depth === 1 && + parentPaneKey !== undefined && + parsePaneKey(parentPaneKey)?.tabId === row.tab.id + ) { + return undefined + } + return getAgentRowConversationName(row.tab, row.agentType, generatedTitlesEnabled) ?? undefined +} diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts new file mode 100644 index 00000000000..a17776433a3 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-filter-options.ts @@ -0,0 +1,27 @@ +import type { AppState } from '@/store/types' +import type { DashboardFilterOptions } from '../../../../shared/dashboard-snapshot' +import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' +import { boundedLabel } from './dashboard-card-labels' +import type { ActiveDashboardWorkspace } from './dashboard-snapshot-workspaces' + +export function buildDashboardSnapshotFilterOptions( + state: Partial>, + activeWorkspaces: ActiveDashboardWorkspace[] +): DashboardFilterOptions { + const projects = [ + ...new Map(activeWorkspaces.map((workspace) => [workspace.projectId, workspace])).values() + ].map((workspace) => ({ + id: workspace.projectId, + label: boundedLabel(workspace.projectName) + })) + const workspaceStatuses = ( + state.workspaceStatuses && state.workspaceStatuses.length > 0 + ? state.workspaceStatuses + : DEFAULT_WORKSPACE_STATUSES + ).map((status) => ({ + id: status.id, + label: status.label, + color: status.color + })) + return { projects, workspaceStatuses } +} diff --git a/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts new file mode 100644 index 00000000000..a83619511f8 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts @@ -0,0 +1,124 @@ +import type { AppState } from '@/store/types' +import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' +import type { + DashboardCard, + DashboardCardHostKind, + DashboardCardWorkspaceKind +} from '../../../../shared/dashboard-snapshot' +import type { RepoIcon } from '../../../../shared/repo-icon' +import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { isFolderRepo } from '../../../../shared/repo-kind' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' + +export type ActiveDashboardWorkspace = { + projectId: string + projectName: string + repo: AppState['repos'][number] | null + repoIcon: RepoIcon | null + worktree: AppState['worktreesByRepo'][string][number] & { parentWorktreeId?: string | null } + workspaceKind: DashboardCardWorkspaceKind + remoteHostKind: Extract | null +} + +type DashboardWorkspaceState = Pick & + Partial> + +function remoteHostKind( + connectionId: string | null | undefined, + executionHostId: string | null | undefined +): ActiveDashboardWorkspace['remoteHostKind'] { + if (connectionId || executionHostId?.startsWith('ssh:')) { + return 'ssh' + } + return executionHostId && executionHostId !== 'local' ? 'remote' : null +} + +export function collectActiveDashboardWorkspaces( + state: DashboardWorkspaceState, + includeMapMetadata = true +): ActiveDashboardWorkspace[] { + const workspaces: ActiveDashboardWorkspace[] = [] + const seenWorkspaceIds = new Set() + + for (const repo of state.repos ?? []) { + for (const worktree of state.worktreesByRepo?.[repo.id] ?? []) { + if (worktree.isArchived) { + continue + } + seenWorkspaceIds.add(worktree.id) + workspaces.push({ + projectId: repo.id, + projectName: repo.displayName, + repo, + repoIcon: repo.repoIcon ?? null, + worktree, + workspaceKind: includeMapMetadata && isFolderRepo(repo) ? 'folder' : 'worktree', + remoteHostKind: includeMapMetadata + ? remoteHostKind(repo.connectionId, worktree.hostId ?? repo.executionHostId) + : null + }) + } + } + + const projectGroupsById = new Map( + (state.projectGroups ?? []).map((projectGroup) => [projectGroup.id, projectGroup]) + ) + for (const folderWorkspace of state.folderWorkspaces ?? []) { + const worktree = folderWorkspaceToWorktree(folderWorkspace) + if (folderWorkspace.isArchived || seenWorkspaceIds.has(worktree.id)) { + continue + } + const projectGroup = projectGroupsById.get(folderWorkspace.projectGroupId) + workspaces.push({ + projectId: `folder-workspace:${folderWorkspace.projectGroupId}`, + projectName: projectGroup?.name ?? folderWorkspace.name, + repo: null, + repoIcon: null, + worktree, + workspaceKind: 'folder', + remoteHostKind: includeMapMetadata + ? remoteHostKind( + folderWorkspace.connectionId ?? projectGroup?.connectionId, + worktree.hostId ?? projectGroup?.executionHostId + ) + : null + }) + } + return workspaces +} + +export function dashboardCardHostKind( + workspace: ActiveDashboardWorkspace, + ptyId: string | null, + terminalInput: DashboardCard['terminalInput'], + clientPlatform: NodeJS.Platform +): DashboardCardHostKind { + if (workspace.remoteHostKind) { + return workspace.remoteHostKind + } + if (ptyId && parseAppSshPtyId(ptyId)) { + return 'ssh' + } + if (ptyId && getRemoteRuntimePtyEnvironmentId(ptyId)) { + return 'remote' + } + return clientPlatform === 'win32' && terminalInput?.hostPlatform === 'linux' ? 'wsl' : 'local' +} + +export function dashboardCardMapWorkspaceMetadata( + workspace: ActiveDashboardWorkspace, + ptyId: string | null, + terminalInput: DashboardCard['terminalInput'], + clientPlatform: NodeJS.Platform +): { + hostKind: DashboardCardHostKind + executionHostId: ExecutionHostId + workspaceKind: DashboardCardWorkspaceKind +} { + return { + hostKind: dashboardCardHostKind(workspace, ptyId, terminalInput, clientPlatform), + executionHostId: getWorktreeExecutionHostId(workspace.worktree, workspace.repo ?? undefined), + workspaceKind: workspace.workspaceKind + } +} diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts new file mode 100644 index 00000000000..e7c0c04d85c --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { DASHBOARD_MAX_LAUNCH_WORKTREES } from '../../../../shared/dashboard-snapshot' +import type { DashboardCard, DashboardWorkspace } from '../../../../shared/dashboard-snapshot' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { buildDashboardWorktreeLaunchOptions } from './dashboard-worktree-launch-options' + +type LaunchState = Parameters[0] + +function state(overrides: Partial = {}): LaunchState { + return { + repos: [], + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + detectedAgentIds: [], + remoteDetectedAgentIds: {}, + runtimeDetectedAgentIds: {}, + settings: null, + ...overrides + } +} + +function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Ship it', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Dashboard', + startedAt: 1, + finishedAt: null, + stateChangedAt: 1, + unseen: false, + ...overrides + } +} + +describe('buildDashboardWorktreeLaunchOptions', () => { + it('stops at the bound the snapshot validator enforces', () => { + const cards = Array.from({ length: DASHBOARD_MAX_LAUNCH_WORKTREES + 25 }, (_unused, index) => + card({ paneKey: `pane-${index}`, worktreeId: `worktree-${index}` }) + ) + + const options = buildDashboardWorktreeLaunchOptions( + state({ detectedAgentIds: ['codex'] }), + cards + ) + + expect(Object.keys(options)).toHaveLength(DASHBOARD_MAX_LAUNCH_WORKTREES) + }) + + it('combines local detection with proven providers, honoring defaults and disabled agents', () => { + const options = buildDashboardWorktreeLaunchOptions( + state({ + detectedAgentIds: ['claude', 'codex'], + settings: { + defaultTuiAgent: 'codex', + disabledTuiAgents: ['claude'] + } as LaunchState['settings'] + }), + [card(), card({ paneKey: 'pane-2', agentType: 'gemini' })] + ) + + expect(options).toEqual({ 'worktree-1': ['codex', 'gemini'] }) + }) + + it('publishes detected launch choices for workspaces without cards', () => { + const workspace: DashboardWorkspace = { + repoId: 'repo-1', + worktreeId: 'empty-worktree', + repoName: 'Orca', + worktreeName: 'Empty', + hostKind: 'local', + executionHostId: 'local', + workspaceKind: 'worktree' + } + const options = buildDashboardWorktreeLaunchOptions( + state({ detectedAgentIds: ['claude', 'codex'] }), + [], + [workspace] + ) + + expect(options).toEqual({ 'empty-worktree': ['claude', 'codex'] }) + }) + + it('uses each git workspace execution host instead of local detection', () => { + const options = buildDashboardWorktreeLaunchOptions( + state({ + repos: [ + { id: 'repo-ssh', connectionId: 'ssh-1' }, + { id: 'repo-runtime', executionHostId: 'runtime:hub-1' } + ] as LaunchState['repos'], + worktreesByRepo: { + 'repo-ssh': [{ id: 'ssh-worktree', repoId: 'repo-ssh' }], + 'repo-runtime': [{ id: 'runtime-worktree', repoId: 'repo-runtime' }] + } as unknown as LaunchState['worktreesByRepo'], + detectedAgentIds: ['claude'], + remoteDetectedAgentIds: { 'ssh-1': ['grok'] }, + runtimeDetectedAgentIds: { 'hub-1': ['aider'] } + }), + [ + card({ repoId: 'repo-ssh', worktreeId: 'ssh-worktree', agentType: 'grok' }), + card({ repoId: 'repo-runtime', worktreeId: 'runtime-worktree', agentType: 'aider' }) + ] + ) + + expect(options).toEqual({ 'ssh-worktree': ['grok'], 'runtime-worktree': ['aider'] }) + }) + + it('resolves folder workspace detection through its project host', () => { + const worktreeId = folderWorkspaceKey('folder-1') + const options = buildDashboardWorktreeLaunchOptions( + state({ + folderWorkspaces: [ + { id: 'folder-1', projectGroupId: 'group-1', connectionId: 'ssh-folder' } + ] as LaunchState['folderWorkspaces'], + projectGroups: [{ id: 'group-1' }] as LaunchState['projectGroups'], + remoteDetectedAgentIds: { 'ssh-folder': ['goose'] } + }), + [card({ repoId: 'folder-workspace:group-1', worktreeId })] + ) + + expect(options).toEqual({ [worktreeId]: ['codex', 'goose'] }) + }) +}) diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts new file mode 100644 index 00000000000..16ca6c1f795 --- /dev/null +++ b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts @@ -0,0 +1,127 @@ +import type { AppState } from '@/store/types' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import { + DASHBOARD_MAX_LAUNCH_WORKTREES, + type DashboardCard, + type DashboardWorkspace +} from '../../../../shared/dashboard-snapshot' +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { + filterEnabledTuiAgents, + TUI_AGENT_AUTO_PICK_ORDER +} from '../../../../shared/tui-agent-selection' +import type { TuiAgent } from '../../../../shared/types' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +export type DashboardLaunchDetectionState = Pick< + AppState, + | 'detectedAgentIds' + | 'folderWorkspaces' + | 'projectGroups' + | 'remoteDetectedAgentIds' + | 'runtimeDetectedAgentIds' +> + +type DashboardLaunchOptionState = Pick & + Partial + +type DashboardLaunchCatalog = { + foldersById: Map + groupsById: Map + reposById: Map + worktreesByRepoAndId: Map> +} + +function buildDashboardLaunchCatalog(state: DashboardLaunchOptionState): DashboardLaunchCatalog { + return { + foldersById: new Map((state.folderWorkspaces ?? []).map((folder) => [folder.id, folder])), + groupsById: new Map((state.projectGroups ?? []).map((group) => [group.id, group])), + reposById: new Map((state.repos ?? []).map((repo) => [repo.id, repo])), + worktreesByRepoAndId: new Map( + Object.entries(state.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [ + repoId, + new Map(worktrees.map((worktree) => [worktree.id, worktree])) + ]) + ) + } +} + +function detectedAgentsForWorktree( + state: DashboardLaunchOptionState, + worktreeId: string, + repoId: string, + catalog: DashboardLaunchCatalog +): readonly TuiAgent[] { + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folder = catalog.foldersById.get(workspaceScope.folderWorkspaceId) + const group = folder ? catalog.groupsById.get(folder.projectGroupId) : undefined + const host = parseExecutionHostId(group?.executionHostId) + if (host?.kind === 'runtime') { + return state.runtimeDetectedAgentIds?.[host.environmentId] ?? [] + } + const connectionId = folder?.connectionId ?? group?.connectionId + return connectionId ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) : [] + } + + const worktree = catalog.worktreesByRepoAndId.get(repoId)?.get(worktreeId) + const repo = catalog.reposById.get(worktree?.repoId ?? repoId) + const host = parseExecutionHostId(worktree?.hostId ?? repo?.executionHostId) + if (host?.kind === 'runtime') { + return state.runtimeDetectedAgentIds?.[host.environmentId] ?? [] + } + const connectionId = host?.kind === 'ssh' ? host.targetId : repo?.connectionId + return connectionId + ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) + : (state.detectedAgentIds ?? []) +} + +/** Host-detected choices plus providers already proven to run in the workspace. */ +export function buildDashboardWorktreeLaunchOptions( + state: DashboardLaunchOptionState, + cards: readonly DashboardCard[], + workspaces: readonly DashboardWorkspace[] = [] +): Record { + const catalog = buildDashboardLaunchCatalog(state) + const cardsByWorktreeId = new Map() + const repoIdByWorktreeId = new Map( + workspaces.map((workspace) => [workspace.worktreeId, workspace.repoId]) + ) + for (const card of cards) { + repoIdByWorktreeId.set(card.worktreeId, card.repoId) + const existing = cardsByWorktreeId.get(card.worktreeId) + if (existing) { + existing.push(card) + } else { + cardsByWorktreeId.set(card.worktreeId, [card]) + } + } + + const result: Record = {} + for (const [worktreeId, repoId] of repoIdByWorktreeId) { + // Past the validator's bound the snapshot itself would be rejected, so the + // launcher goes quiet for the tail rather than taking the board down. + if (Object.keys(result).length >= DASHBOARD_MAX_LAUNCH_WORKTREES) { + break + } + const worktreeCards = cardsByWorktreeId.get(worktreeId) ?? [] + const available = new Set( + detectedAgentsForWorktree(state, worktreeId, repoId, catalog) + ) + for (const card of worktreeCards) { + if (isTuiAgent(card.agentType)) { + available.add(card.agentType) + } + } + const enabled = filterEnabledTuiAgents( + TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => available.has(agent)), + state.settings?.disabledTuiAgents + ) + const preferred = state.settings?.defaultTuiAgent + result[worktreeId] = + preferred && preferred !== 'blank' && enabled.includes(preferred) + ? [preferred, ...enabled.filter((agent) => agent !== preferred)] + : enabled + } + return result +} diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts new file mode 100644 index 00000000000..a207244cdb9 --- /dev/null +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getKnownWorktreeById: vi.fn(), + setActiveWorktree: vi.fn(), + launchAgentInNewTab: vi.fn(), + getExecutionHostIdForWorktree: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + settings: null, + getKnownWorktreeById: mocks.getKnownWorktreeById, + setActiveWorktree: mocks.setActiveWorktree + }) + } +})) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ + launchAgentInNewTab: mocks.launchAgentInNewTab +})) +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: mocks.getExecutionHostIdForWorktree +})) + +import { launchDashboardAgent } from './launch-dashboard-agent' + +describe('launchDashboardAgent', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getExecutionHostIdForWorktree.mockReturnValue('ssh:docs') + mocks.getKnownWorktreeById.mockReturnValue({ id: 'folder:docs' }) + mocks.launchAgentInNewTab.mockReturnValue({ tabId: 'tab-1' }) + }) + + it('activates a folder or git workspace on its execution host before launching', () => { + expect(launchDashboardAgent({ worktreeId: 'folder:docs', agent: 'codex' })).toBe(true) + expect(mocks.getKnownWorktreeById).toHaveBeenCalledWith('folder:docs', 'ssh:docs') + expect(mocks.setActiveWorktree).toHaveBeenCalledWith('folder:docs', 'ssh:docs') + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith({ + agent: 'codex', + worktreeId: 'folder:docs', + launchSource: 'unknown' + }) + }) +}) diff --git a/src/renderer/src/components/dashboard/launch-dashboard-agent.ts b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts new file mode 100644 index 00000000000..be3b644e5a6 --- /dev/null +++ b/src/renderer/src/components/dashboard/launch-dashboard-agent.ts @@ -0,0 +1,23 @@ +import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { useAppStore } from '@/store' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import type { DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' + +/** Starts the requested agent through the same host-aware tab path as Quick Launch. */ +export function launchDashboardAgent({ worktreeId, agent }: DashboardSpawnAgentArgs): boolean { + const state = useAppStore.getState() + const executionHostId = getExecutionHostIdForWorktree(state, worktreeId) + const worktree = state.getKnownWorktreeById(worktreeId, executionHostId) + if (!worktree || !isTuiAgentEnabled(agent, state.settings?.disabledTuiAgents)) { + return false + } + state.setActiveWorktree(worktreeId, executionHostId) + return ( + launchAgentInNewTab({ + agent, + worktreeId, + launchSource: 'unknown' + }) !== null + ) +} diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx b/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx new file mode 100644 index 00000000000..02f9b5a5386 --- /dev/null +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + state: { + repos: [], + worktreesByRepo: {}, + tabsByWorktree: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {}, + folderWorkspaces: [{ id: 'folder-1' }], + acknowledgedAgentsByPaneKey: {} as Record, + unrelatedEpoch: 0, + agentStatusEpoch: 0 + }, + buildDashboardSnapshot: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state) +})) + +vi.mock('./build-dashboard-snapshot', () => ({ + buildDashboardSnapshot: mocks.buildDashboardSnapshot +})) + +import { useAgentBucketCounts } from './useAgentBucketCounts' + +afterEach(() => { + cleanup() + vi.clearAllMocks() + mocks.state.acknowledgedAgentsByPaneKey = {} + mocks.state.unrelatedEpoch = 0 +}) + +describe('useAgentBucketCounts', () => { + it('includes folder workspaces in the count snapshot inputs', () => { + mocks.buildDashboardSnapshot.mockImplementation((state: { folderWorkspaces?: unknown[] }) => ({ + generatedAt: 1, + cards: state.folderWorkspaces?.length ? [{ bucket: 'working' }] : [] + })) + + const { result } = renderHook(() => useAgentBucketCounts()) + + expect(result.current).toEqual({ attention: 0, working: 1, done: 0, idle: 0 }) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ folderWorkspaces: mocks.state.folderWorkspaces }), + expect.any(Number), + { includeCardDetails: false, includeFilterOptions: false } + ) + }) + + it('moves acknowledged completions to idle without recomputing for unrelated writes', () => { + mocks.buildDashboardSnapshot.mockImplementation( + (state: { acknowledgedAgentsByPaneKey?: Record }) => ({ + generatedAt: 1, + cards: [ + { + bucket: state.acknowledgedAgentsByPaneKey?.['pane-done'] ? 'idle' : 'done' + } + ] + }) + ) + const { result, rerender } = renderHook(() => useAgentBucketCounts()) + + expect(result.current).toEqual({ attention: 0, working: 0, done: 1, idle: 0 }) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) + + mocks.state.unrelatedEpoch += 1 + rerender() + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) + + mocks.state.acknowledgedAgentsByPaneKey = { 'pane-done': 1 } + rerender() + expect(result.current).toEqual({ attention: 0, working: 0, done: 0, idle: 1 }) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index 26b3fac9f3c..fe55b6e405c 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -25,6 +25,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, + folderWorkspaces, + acknowledgedAgentsByPaneKey, agentStatusEpoch } = useAppStore( useShallow((s) => ({ @@ -38,6 +40,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId: s.terminalLayoutsByTabId, ptyIdsByTabId: s.ptyIdsByTabId, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + folderWorkspaces: s.folderWorkspaces, + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, agentStatusEpoch: s.agentStatusEpoch })) ) @@ -55,8 +59,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, - // Counts do not render acknowledgement state, so avoid subscribing the sidebar to it. - acknowledgedAgentsByPaneKey: {}, + folderWorkspaces, + acknowledgedAgentsByPaneKey, // Same: counts never render a card's conversation name, so the // generated-title gate is moot and the sidebar stays off settings. settings: null @@ -86,6 +90,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { terminalLayoutsByTabId, ptyIdsByTabId, runtimePaneTitlesByTabId, + folderWorkspaces, + acknowledgedAgentsByPaneKey, agentStatusEpoch ]) } diff --git a/src/renderer/src/components/dashboard/useDashboardData.ts b/src/renderer/src/components/dashboard/useDashboardData.ts index 65c57ab2f4e..06926f919a0 100644 --- a/src/renderer/src/components/dashboard/useDashboardData.ts +++ b/src/renderer/src/components/dashboard/useDashboardData.ts @@ -23,6 +23,7 @@ export type DashboardAgentRow = { startedAt: number lineage?: { depth: 0 | 1 + parentPaneKey?: string isFirstSibling: boolean isLastSibling: boolean childCount: number diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index e4d853d6e28..c632f19bc4e 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -139,15 +139,37 @@ describe('useDashboardPopoutBridge', () => { expect(mocks.onPopoutOpenChanged).toHaveBeenCalledTimes(1) expect(mocks.subscribeStore).not.toHaveBeenCalled() + expect(mocks.buildDashboardSnapshot).not.toHaveBeenCalled() await act(async () => mocks.onPopoutOpenChanged.mock.calls[0][0](true)) expect(mocks.subscribeStore).toHaveBeenCalledTimes(1) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) const unsubscribe = mocks.subscribeStore.mock.results[0]?.value as () => void + const notifyStore = mocks.subscribeStore.mock.calls[0][0] await act(async () => mocks.onPopoutOpenChanged.mock.calls[0][0](false)) expect(unsubscribe).toHaveBeenCalledTimes(1) + const previousState = makeSnapshotWatchState() + await act(async () => notifyStore({ ...previousState, agentStatusEpoch: 1 }, previousState)) + expect(mocks.buildDashboardSnapshot).toHaveBeenCalledTimes(1) + }) + + it('reveals the agent on its exact execution host', async () => { + await act(async () => root.render()) + + await act(async () => + mocks.onRevealAgent.mock.calls[0][0]({ + repoId: 'repo-1', + worktreeId: 'shared-worktree', + executionHostId: 'runtime:env-1', + tabId: 'tab-1', + leafId: 'leaf-1' + }) + ) + + expect(mocks.setActiveWorktree).toHaveBeenCalledWith('shared-worktree', 'runtime:env-1') }) it('ignores unrelated store writes while retaining every snapshot input', () => { @@ -158,7 +180,6 @@ describe('useDashboardPopoutBridge', () => { 'repos', 'worktreesByRepo', 'tabsByWorktree', - 'agentStatusByPaneKey', 'retainedAgentsByPaneKey', 'migrationUnsupportedByPtyId', 'runtimeAgentOrchestrationByPaneKey', @@ -177,9 +198,15 @@ describe('useDashboardPopoutBridge', () => { key ).toBe(true) } + expect( + dashboardSnapshotInputsChanged( + { ...previousState, agentStatusByPaneKey: { ...previousState.agentStatusByPaneKey } }, + previousState + ) + ).toBe(false) expect( dashboardSnapshotInputsChanged({ ...previousState, agentStatusEpoch: 1 }, previousState) - ).toBe(true) + ).toBe(false) // Why: each card's preview terminal keys against a host-input profile // derived from these. Not republishing leaves the pop-out encoding bytes @@ -291,7 +318,7 @@ describe('useDashboardPopoutBridge repo icon publishing', () => { const previousState = makeSnapshotWatchState() act(() => mocks.subscribeStore.mock.calls[0][0]( - { ...previousState, agentStatusEpoch: 1 }, + { ...previousState, acknowledgedAgentsByPaneKey: { pane: 1 } }, previousState ) ) @@ -300,6 +327,19 @@ describe('useDashboardPopoutBridge repo icon publishing', () => { const previousState = makeSnapshotWatchState() act(() => mocks.subscribeStore.mock.calls[0][0]({ ...previousState }, previousState)) } + const notifyStatusChurn = (): void => { + const previousState = makeSnapshotWatchState() + act(() => + mocks.subscribeStore.mock.calls[0][0]( + { + ...previousState, + agentStatusByPaneKey: { ...previousState.agentStatusByPaneKey }, + agentStatusEpoch: previousState.agentStatusEpoch + 1 + }, + previousState + ) + ) + } const mountAndOpen = async (): Promise => { await act(async () => root.render()) setPopoutOpen(true) @@ -330,6 +370,19 @@ describe('useDashboardPopoutBridge repo icon publishing', () => { expect(mocks.publishSnapshot).toHaveBeenCalledTimes(1) }) + it('does no snapshot work during live status churn', async () => { + await mountAndOpen() + mocks.buildDashboardSnapshot.mockClear() + mocks.publishSnapshot.mockClear() + + for (let index = 0; index < 100; index += 1) { + notifyStatusChurn() + } + + expect(mocks.buildDashboardSnapshot).not.toHaveBeenCalled() + expect(mocks.publishSnapshot).not.toHaveBeenCalled() + }) + // A burst collapses onto the trailing timer, so that edge carries most of the // republishes this PR exists to slim down. it('omits the icon map on the throttled trailing republish', async () => { diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 0c0d26909cc..2c42757036d 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -1,8 +1,10 @@ import { useEffect } from 'react' import { useAppStore, type AppState } from '@/store' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' +import { runSleepWorktree } from '../sidebar/sleep-worktree-flow' import type { RepoIcon } from '../../../../shared/repo-icon' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' +import { launchDashboardAgent } from './launch-dashboard-agent' // Why: cap snapshot rebuilds during bursts of agent-status pings. The board is a // glanceable surface, so ~4 updates/sec is plenty and keeps the cross-worktree @@ -44,7 +46,6 @@ export function dashboardSnapshotInputsChanged( state.repos !== previousState.repos || state.worktreesByRepo !== previousState.worktreesByRepo || state.tabsByWorktree !== previousState.tabsByWorktree || - state.agentStatusByPaneKey !== previousState.agentStatusByPaneKey || state.retainedAgentsByPaneKey !== previousState.retainedAgentsByPaneKey || state.migrationUnsupportedByPtyId !== previousState.migrationUnsupportedByPtyId || state.runtimeAgentOrchestrationByPaneKey !== previousState.runtimeAgentOrchestrationByPaneKey || @@ -57,8 +58,11 @@ export function dashboardSnapshotInputsChanged( // Why: settings controls idle visibility and generated conversation names. state.settings !== previousState.settings || state.workspaceStatuses !== previousState.workspaceStatuses || - // Why: freshness can change a bucket without replacing any backing map. - state.agentStatusEpoch !== previousState.agentStatusEpoch || + state.detectedAgentIds !== previousState.detectedAgentIds || + state.remoteDetectedAgentIds !== previousState.remoteDetectedAgentIds || + state.runtimeDetectedAgentIds !== previousState.runtimeDetectedAgentIds || + // Live hook status is relayed straight from main to the pop-out. Rebuilding + // every card here would put map refresh work on the main renderer's hot path. // Why: each card carries the host-input profile its preview terminal keys // against, and the pop-out cannot re-derive it. Every slice that resolves // an execution host must republish or the preview keeps encoding bytes for @@ -103,12 +107,30 @@ function watchSnapshotInputs(onChanged: () => void): () => void { * the agent's worktree and focus its pane in this (main) window. */ export function useDashboardPopoutBridge(enabled: boolean): void { + useEffect(() => { + if (!enabled) { + return + } + return window.api.dashboard.onSpawnAgent?.(launchDashboardAgent) + }, [enabled]) + + // Sleeping from the popout runs the shared teardown here, where the store and + // the live terminal panes are — the popout only names the workspace. + useEffect(() => { + if (!enabled) { + return + } + return window.api.dashboard.onSleepWorkspace?.(({ worktreeId }) => { + void runSleepWorktree(worktreeId) + }) + }, [enabled]) + useEffect(() => { if (!enabled) { return } return window.api.dashboard.onRevealAgent((args) => { - useAppStore.getState().setActiveWorktree(args.worktreeId) + useAppStore.getState().setActiveWorktree(args.worktreeId, args.executionHostId) activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true }) }) }, [enabled]) diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 43933bfae62..9782f91c697 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -51,6 +51,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) const removedRuntimeEnvironmentIds = useAppStore((s) => s.removedRuntimeEnvironmentIds) const paneForegroundAgentByPaneKey = useAppStore((s) => s.paneForegroundAgentByPaneKey) + const detectedAgentIds = useAppStore((s) => s.detectedAgentIds) + const remoteDetectedAgentIds = useAppStore((s) => s.remoteDetectedAgentIds) + const runtimeDetectedAgentIds = useAppStore((s) => s.runtimeDetectedAgentIds) // Why: freshness can flip a bucket without any backing map changing; the epoch // ticks on the freshness boundary so the memo re-derives stale-decayed cards. const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) @@ -87,6 +90,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds, paneForegroundAgentByPaneKey, + detectedAgentIds, + remoteDetectedAgentIds, + runtimeDetectedAgentIds, // Why: read non-reactively — resolveWindowsShiftEnterEncoding takes // launch identity but never routes on it, so subscribing would only // rebuild the board. Matches the bridge's republish gate. @@ -122,6 +128,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds, paneForegroundAgentByPaneKey, + detectedAgentIds, + remoteDetectedAgentIds, + runtimeDetectedAgentIds, agentStatusEpoch ] ) diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 852f1d84337..5a767f830f4 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -138,7 +138,7 @@ describe('ExperimentalPane', () => { ) - expect(settings.experimentalAgentDashboardPopout).toBe(false) + expect(settings.experimentalAgentDashboardPopout).toBeUndefined() expect(markup).toContain('Agent Dashboard') expect(markup).toContain('Monitor agents that need you, are working, or are done') expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain( diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx new file mode 100644 index 00000000000..5eeae6f145c --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarEntry.tsx @@ -0,0 +1,90 @@ +import { LayoutDashboard, MessageCircleQuestion } from 'lucide-react' +import { useAppStore } from '@/store' +import { cn } from '@/lib/utils' +import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' +import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' +import { translate } from '@/i18n/i18n' + +const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = { + working: 'bg-yellow-500', + done: 'bg-emerald-500', + idle: 'bg-neutral-500/50' +} + +function dashboardBucketLabel(bucket: DashboardBucket): string { + switch (bucket) { + case 'attention': + return translate('dashboardPopout.bucket.attention', 'Needs You') + case 'working': + return translate('dashboardPopout.bucket.working', 'Working') + case 'done': + return translate('dashboardPopout.bucket.done', 'Done') + case 'idle': + return translate('dashboardPopout.bucket.idle', 'Idle') + } +} + +function DashboardBucketCounts({ + counts, + showIdle +}: { + counts: Record + showIdle: boolean +}): React.JSX.Element | null { + const active = DASHBOARD_BUCKET_ORDER.filter( + (bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle) + ) + if (active.length === 0) { + return null + } + return ( + + {active.map((bucket) => ( + + {bucket === 'attention' ? ( + + ) : ( + + )} + {counts[bucket]} + + ))} + + ) +} + +export default function AgentDashboardSidebarEntry(): React.JSX.Element { + const dashboardBucketCounts = useAgentBucketCounts() + const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true) + const openAsPopout = useAppStore((s) => s.settings?.experimentalAgentDashboardMode === 'popout') + const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) + const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) + + return ( + + ) +} diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx new file mode 100644 index 00000000000..1b90f264c3e --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import AgentDashboardSidebarHost from './AgentDashboardSidebarHost' + +vi.mock('@/components/dashboard/AgentDashboardDrawer', () => ({ + AgentDashboardDrawer: () => null +})) + +const initialState = useAppStore.getInitialState() + +beforeEach(() => { + useAppStore.setState({ agentDashboardDrawerOpen: false }, false) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) +}) + +describe('AgentDashboardSidebarHost', () => { + it('closes the dashboard when the workspace board opens', async () => { + useAppStore.setState({ agentDashboardDrawerOpen: true }) + render( + + ) + + await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)) + }) + + it('closes the workspace board when the dashboard opens', async () => { + const closeWorkspaceBoard = vi.fn() + const view = render( + + ) + + act(() => useAppStore.setState({ agentDashboardDrawerOpen: true })) + view.rerender( + + ) + + await waitFor(() => expect(closeWorkspaceBoard).toHaveBeenCalledOnce()) + }) + + it('clears an open dashboard when the sidebar closes', async () => { + useAppStore.setState({ agentDashboardDrawerOpen: true }) + render( + + ) + + await waitFor(() => expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)) + }) +}) diff --git a/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx new file mode 100644 index 00000000000..222571dc0be --- /dev/null +++ b/src/renderer/src/components/sidebar/AgentDashboardSidebarHost.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { AgentDashboardDrawer } from '@/components/dashboard/AgentDashboardDrawer' + +type AgentDashboardSidebarHostProps = { + sidebarOpen: boolean + workspaceBoardOpen: boolean + closeWorkspaceBoard: () => void + leftSidebarStyle?: React.CSSProperties + statusBarVisible: boolean +} + +/** Opt-in dashboard coordination stays outside the normal sidebar path. */ +export default function AgentDashboardSidebarHost({ + sidebarOpen, + workspaceBoardOpen, + closeWorkspaceBoard, + leftSidebarStyle, + statusBarVisible +}: AgentDashboardSidebarHostProps): React.JSX.Element | null { + const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) + const setDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) + + useEffect(() => { + if (!sidebarOpen && drawerOpen) { + setDrawerOpen(false) + } + }, [drawerOpen, setDrawerOpen, sidebarOpen]) + useEffect(() => { + if (drawerOpen) { + closeWorkspaceBoard() + } + }, [closeWorkspaceBoard, drawerOpen]) + useEffect(() => { + if (workspaceBoardOpen) { + setDrawerOpen(false) + } + }, [setDrawerOpen, workspaceBoardOpen]) + + return sidebarOpen ? ( + + ) : null +} diff --git a/src/renderer/src/components/sidebar/Sidebar.test.tsx b/src/renderer/src/components/sidebar/Sidebar.test.tsx index 36db65daeb1..6704020676b 100644 --- a/src/renderer/src/components/sidebar/Sidebar.test.tsx +++ b/src/renderer/src/components/sidebar/Sidebar.test.tsx @@ -219,63 +219,4 @@ describe('Sidebar', () => { expect(fetchAllWorktrees).not.toHaveBeenCalled() }) - - describe('companion board mutual exclusion', () => { - function renderWithDashboardOpen(): { - view: ReturnType - setAgentDashboardDrawerOpen: ReturnType - } { - setSidebarState(getDefaultSettings(tmpdir())) - const setAgentDashboardDrawerOpen = vi.fn() - mocks.state = { ...mocks.state, agentDashboardDrawerOpen: true, setAgentDashboardDrawerOpen } - mocks.panel = { - workspaceBoardOpen: false, - workspaceBoardRenderedOpen: false, - workspaceBoardDragPreviewOpen: false - } - return { view: render(sidebarElement()), setAgentDashboardDrawerOpen } - } - - it('keeps the agent dashboard open while a worktree drag only previews the board', () => { - const { view, setAgentDashboardDrawerOpen } = renderWithDashboardOpen() - - mocks.panel = { - workspaceBoardOpen: false, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: true - } - view.rerender(sidebarElement()) - - expect(setAgentDashboardDrawerOpen).not.toHaveBeenCalled() - }) - - it('closes the agent dashboard when the workspace board is actually opened', () => { - const { view, setAgentDashboardDrawerOpen } = renderWithDashboardOpen() - - mocks.panel = { - workspaceBoardOpen: true, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: false - } - view.rerender(sidebarElement()) - - expect(setAgentDashboardDrawerOpen).toHaveBeenCalledWith(false) - }) - - it('closes the workspace board when the agent dashboard opens', () => { - setSidebarState(getDefaultSettings(tmpdir())) - mocks.panel = { - workspaceBoardOpen: true, - workspaceBoardRenderedOpen: true, - workspaceBoardDragPreviewOpen: false - } - const view = render(sidebarElement()) - mocks.closeWorkspaceBoard.mockClear() - - mocks.state = { ...mocks.state, agentDashboardDrawerOpen: true } - view.rerender(sidebarElement()) - - expect(mocks.closeWorkspaceBoard).toHaveBeenCalled() - }) - }) }) diff --git a/src/renderer/src/components/sidebar/SidebarNav.test.tsx b/src/renderer/src/components/sidebar/SidebarNav.test.tsx index 7d041bfb264..9d72ab4d489 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.test.tsx @@ -2,6 +2,7 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { getDefaultSettings } from '../../../../shared/constants' @@ -21,6 +22,7 @@ const mocks = vi.hoisted(() => ({ checkLinearConnection: vi.fn(), hasPairedMobileDevice: false, agentBucketCounts: { attention: 0, working: 0, done: 0, idle: 0 }, + getAgentBucketCounts: vi.fn(), dismissMobileOnboardingBadge: vi.fn(), setSetupGuideSidebarDismissed: vi.fn() })) @@ -41,7 +43,10 @@ vi.mock('@/components/activity/useActivityUnreadCount', () => ({ })) vi.mock('@/components/dashboard/useAgentBucketCounts', () => ({ - useAgentBucketCounts: () => mocks.agentBucketCounts + useAgentBucketCounts: () => { + mocks.getAgentBucketCounts() + return mocks.agentBucketCounts + } })) vi.mock('@/hooks/useShortcutLabel', () => ({ @@ -243,6 +248,7 @@ describe('SidebarNav', () => { const container = await renderSidebarNav() expect(queryButtonByText(container, 'Agent Dashboard')).toBeNull() + expect(mocks.getAgentBucketCounts).not.toHaveBeenCalled() }) it('mounts the Agent Dashboard row after opt-in', async () => { @@ -254,7 +260,8 @@ describe('SidebarNav', () => { }) const container = await renderSidebarNav() - expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull() + await waitFor(() => expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull()) + expect(mocks.getAgentBucketCounts).toHaveBeenCalledTimes(1) }) it('uses a question glyph only for the Needs You count', async () => { @@ -268,6 +275,9 @@ describe('SidebarNav', () => { }) const container = await renderSidebarNav() + await waitFor(() => + expect(container.querySelector('[aria-label="Needs You: 2"]')).not.toBeNull() + ) const attention = container.querySelector('[aria-label="Needs You: 2"]') const working = container.querySelector('[aria-label="Working: 3"]') const done = container.querySelector('[aria-label="Done: 1"]') diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index d2a36fb79e4..b14295d3714 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,19 +1,9 @@ import React from 'react' -import { - Bell, - CalendarClock, - EyeOff, - LayoutDashboard, - MessageCircleQuestion, - Search, - Smartphone -} from 'lucide-react' +import { Bell, CalendarClock, EyeOff, Search, Smartphone } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import type { GlobalSettings } from '../../../../shared/types' -import { DASHBOARD_BUCKET_ORDER, type DashboardBucket } from '../../../../shared/dashboard-snapshot' -import { useAgentBucketCounts } from '@/components/dashboard/useAgentBucketCounts' import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount' import { useShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' @@ -25,6 +15,7 @@ import { SetupGuideSidebarEntry } from './SetupGuideSidebarEntry' import { SidebarTaskNavButton } from './SidebarTaskNavButton' import { HideSidebarMenu } from './sidebar-nav-controls' import { translate } from '@/i18n/i18n' +import { lazyWithRetry } from '@/lib/lazy-with-retry' export { getSetupGuideSidebarEntryReady, shouldShowSetupGuideEntry } from './SetupGuideSidebarEntry' @@ -40,14 +31,6 @@ export function shouldShowAgentDashboardButton( return settings?.experimentalAgentDashboardPopout === true } -// Why: in-window is the default surface; only an explicit 'popout' choice opens -// the separate OS window. -function isAgentDashboardPopoutMode( - settings: Pick | null | undefined -): boolean { - return settings?.experimentalAgentDashboardMode === 'popout' -} - export function shouldShowMobileButton( settings: Pick | null | undefined ): boolean { @@ -60,93 +43,7 @@ export function shouldShowAutomationsButton( return settings?.showAutomationsButton !== false } -const DASHBOARD_BUCKET_DOT_CLASS: Record<'working' | 'done' | 'idle', string> = { - working: 'bg-yellow-500', - done: 'bg-emerald-500', - idle: 'bg-neutral-500/50' -} - -function dashboardBucketLabel(bucket: DashboardBucket): string { - switch (bucket) { - case 'attention': - return translate('dashboardPopout.bucket.attention', 'Needs You') - case 'working': - return translate('dashboardPopout.bucket.working', 'Working') - case 'done': - return translate('dashboardPopout.bucket.done', 'Done') - case 'idle': - return translate('dashboardPopout.bucket.idle', 'Idle') - } -} - -function DashboardBucketCounts({ - counts, - showIdle -}: { - counts: Record - showIdle: boolean -}): React.JSX.Element | null { - const active = DASHBOARD_BUCKET_ORDER.filter( - (bucket) => counts[bucket] > 0 && (bucket !== 'idle' || showIdle) - ) - if (active.length === 0) { - return null - } - return ( - - {active.map((bucket) => ( - - {bucket === 'attention' ? ( - - ) : ( - - )} - {counts[bucket]} - - ))} - - ) -} - -// Why: keep the dashboard's broad aggregate subscriptions out of SidebarNav so -// agent-status churn only updates this opt-in row, not the full navigation. -function AgentDashboardSidebarEntry(): React.JSX.Element { - const dashboardBucketCounts = useAgentBucketCounts() - const showIdle = useAppStore((s) => s.settings?.experimentalAgentDashboardShowIdle === true) - const openAsPopout = useAppStore((s) => isAgentDashboardPopoutMode(s.settings)) - const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen) - const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen) - - return ( - - ) -} +const AgentDashboardSidebarEntry = lazyWithRetry(() => import('./AgentDashboardSidebarEntry')) const SidebarNav = React.memo(function SidebarNav() { // Why: this memo boundary needs its own language subscription, while @@ -159,8 +56,13 @@ const SidebarNav = React.memo(function SidebarNav() { const openModal = useAppStore((s) => s.openModal) const updateSettings = useAppStore((s) => s.updateSettings) const activeView = useAppStore((s) => s.activeView) - const showAgentsButton = useAppStore((s) => shouldShowAgentsButton(s.settings)) - const showAgentDashboardButton = useAppStore((s) => shouldShowAgentDashboardButton(s.settings)) + const experimentalSidebarButtons = useAppStore( + (s) => + (shouldShowAgentsButton(s.settings) ? 1 : 0) | + (shouldShowAgentDashboardButton(s.settings) ? 2 : 0) + ) + const showAgentsButton = (experimentalSidebarButtons & 1) !== 0 + const showAgentDashboardButton = (experimentalSidebarButtons & 2) !== 0 const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings)) const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings)) const automationsActive = activeView === 'automations' @@ -211,7 +113,11 @@ const SidebarNav = React.memo(function SidebarNav() { ) : null} - {showAgentDashboardButton ? : null} + {showAgentDashboardButton ? ( + + + + ) : null} {showAgentsButton ? ( + )} +
+ ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index daebf16e7ad..9a4dc1498f9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10289,6 +10289,11 @@ "description": "Input device used for voice dictation. System default follows the OS microphone setting.", "accessHint": "Allow microphone access to list input devices.", "allowAccess": "Allow access" + }, + "TerminalTccAttributionNotice": { + "body": "The terminal daemon was started by an Orca install that no longer exists, so macOS can’t attribute its commands to Orca — Accessibility and Automation grants are silently ignored (osascript fails with error -25211). Restarting the daemon fixes this; running terminal sessions will close.", + "openManageSessions": "Open Manage Sessions", + "title": "macOS permission grants aren’t reaching terminals" } }, "right": { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 64239245158..131be65a6df 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3224,7 +3224,9 @@ function createPtyApi(): NonNullable['pty']> { listSessions: () => Promise.resolve({ sessions: [], degraded: false }), killAll: () => Promise.resolve({ killedCount: 0, remainingCount: 0, killedSessionIds: [] }), killOne: () => Promise.resolve({ success: false }), - restart: () => Promise.resolve({ success: false }) + restart: () => Promise.resolve({ success: false }), + // Why: web clients can't inspect the host daemon's pid record; 'unknown' keeps the banner hidden. + macTccAttribution: () => Promise.resolve({ health: 'unknown' as const }) } } } diff --git a/src/shared/daemon-lifecycle-telemetry.ts b/src/shared/daemon-lifecycle-telemetry.ts index 1ec13c985f6..61ecdf3f665 100644 --- a/src/shared/daemon-lifecycle-telemetry.ts +++ b/src/shared/daemon-lifecycle-telemetry.ts @@ -8,7 +8,8 @@ export const DAEMON_REPLACE_REASONS = [ 'unhealthy_resolver', 'stale_bundle', 'different_app_path', - 'failed_health_check' + 'failed_health_check', + 'severed_tcc_attribution' ] as const export type DaemonReplaceReason = (typeof DAEMON_REPLACE_REASONS)[number] diff --git a/src/shared/remote-runtime-shared-control-state.ts b/src/shared/remote-runtime-shared-control-state.ts index eba1d4a1be8..c31752cdb56 100644 --- a/src/shared/remote-runtime-shared-control-state.ts +++ b/src/shared/remote-runtime-shared-control-state.ts @@ -208,4 +208,3 @@ export function scheduleSharedControlReconnect(args: { } return { timer, reconnectAttempt: args.reconnectAttempt + 1 } } - From 39ba201ed2fd31bcfe5008ccfca2c7bc41408a61 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:11:01 -0700 Subject: [PATCH 42/46] fix(source-control): prevent long branch names from overlapping chip (#12842) - `truncate` has no effect on inline boxes, so long branch names would overflow their flex item and run under the line-total chip - Adding `block` display forces text truncation with ellipsis instead - Increase gap from 1.5 to 2 so ellipsis doesn't visually merge with chip --- ...source-control-branch-context-row.test.tsx | 25 +++++++++++++++++++ .../source-control-branch-context-row.tsx | 10 +++++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx index 0172dab5dd0..963e4e00a21 100644 --- a/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-branch-context-row.test.tsx @@ -249,6 +249,31 @@ describe('SourceControlBranchContextRow branch line total', () => { expect(markup).not.toContain(' +
+ ) + return (
@@ -220,39 +262,48 @@ export function MobileHeroPairingStep({ ) : null}
-
- - {translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')} - - - -
+ + + + + {/* The row's own 18px gap moves to the Collapsible so the spacing + below stays identical whether the disclosure is open. `!` is + required: mobile-page.css is unlayered and so outranks Tailwind's + utilities layer on specificity ties. */} +
+

+ {translate( + 'auto.components.mobile.MobileHero.directAddressHint', + 'Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away.' + )} +

+ {networkRow} +
+
+ + ) : ( + networkRow + )}
@@ -272,6 +323,7 @@ export function MobileHeroPairingStep({
diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx index e19b527fc10..3196f0e340e 100644 --- a/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.test.tsx @@ -44,6 +44,27 @@ describe('WindowsFirewallNotice', () => { expect(screen.getByText(/TCP port 6768/i)).toBeInTheDocument() }) + it('reassures on Relay that pairing works without the firewall rule', async () => { + const getWindowsFirewallStatus = vi.fn().mockResolvedValue({ + supported: true, + port: 6768, + ruleAllowed: false, + blockingRuleDetected: false, + privateFirewallEnabled: true, + networkCategory: 'private', + inspectionAvailable: true + }) + setMobileApi({ getWindowsFirewallStatus }) + const { rerender } = render( + + ) + expect(await screen.findByText(/allow phone connections through/i)).toBeInTheDocument() + expect(screen.getByText(/still works over Orca Relay/i)).toBeInTheDocument() + + rerender() + expect(screen.queryByText(/still works over Orca Relay/i)).not.toBeInTheDocument() + }) + it('repairs only after explicit user action and hides after success', async () => { const repairWindowsFirewall = vi.fn().mockResolvedValue({ ok: true }) const getWindowsFirewallStatus = vi diff --git a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx index fb9d1827673..5ac7f5caac0 100644 --- a/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx +++ b/src/renderer/src/components/mobile/WindowsFirewallNotice.tsx @@ -10,12 +10,15 @@ import { cn } from '../../lib/utils' type WindowsFirewallNoticeProps = { pairingReady: boolean address?: string + /** Relay offers still carry a LAN endpoint, but a blocked one is not fatal. */ + usingRelay?: boolean className?: string } export function WindowsFirewallNotice({ pairingReady, address, + usingRelay = false, className }: WindowsFirewallNoticeProps): React.JSX.Element | null { const [status, setStatus] = useState(null) @@ -173,6 +176,14 @@ export function WindowsFirewallNotice({ { port: firewallStatus.port } )}

+ {usingRelay ? ( +

+ {translate( + 'auto.components.mobile.WindowsFirewallNotice.relay-note', + 'Pairing still works over Orca Relay — allowing this only adds the faster local connection.' + )} +

+ ) : null}
{networkIsPublic ? ( +
+ ) : null}
- { optionRefs.current['local-only'] = el }} @@ -244,64 +246,10 @@ export function MobilePairingConnectionOptions({ )} description={translate( 'auto.components.settings.MobilePairingConnectionOptions.localDescription', - 'Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.' + 'Phone must be on this Wi‑Fi or connected through Tailscale. No account needed.' )} />
- - {needsSignIn ? ( -
-

- {translate( - 'auto.components.settings.MobilePairingConnectionOptions.signInRequired', - 'Sign in to use Orca Mobile Relay.' - )} -

- -
- ) : null} - - {relayUnavailable ? ( -
-

- {translate( - 'auto.components.settings.MobilePairingConnectionOptions.relayUnavailable', - 'Orca Relay isn’t available in this build. Use LAN.' - )} -

- - {translate( - 'auto.components.settings.MobilePairingConnectionOptions.unavailable', - 'Unavailable' - )} - -
- ) : null}
) } diff --git a/src/renderer/src/components/settings/MobilePairingPathOption.tsx b/src/renderer/src/components/settings/MobilePairingPathOption.tsx new file mode 100644 index 00000000000..191b584d2b9 --- /dev/null +++ b/src/renderer/src/components/settings/MobilePairingPathOption.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +// Why: bespoke radio row instead of SettingsSegmentedControl — needs two-line +// title + description + trailing badge (STYLEGUIDE "real difference in role"). +export function MobilePairingPathOption({ + selected, + onSelect, + title, + description, + trailing, + tabIndex, + disabled = false, + positionInSet, + setSize, + optionRef +}: { + selected: boolean + onSelect: () => void + title: string + description: string + trailing?: ReactNode + tabIndex: number + disabled?: boolean + /** Stated explicitly: the Sign in panel sits between the radios in the DOM. */ + positionInSet: number + setSize: number + optionRef?: (el: HTMLDivElement | null) => void +}): React.JSX.Element { + return ( +
{ + if (disabled) { + return + } + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault() + onSelect() + } + }} + className={cn( + 'flex cursor-pointer items-start gap-3 px-3 py-2.5 outline-none transition-colors', + // Why: match SettingsFormControls focus ring when selected uses bg-accent/40. + 'focus-visible:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-ring/50', + disabled && 'cursor-not-allowed opacity-60', + selected ? 'bg-accent/40' : 'hover:bg-accent/20' + )} + > + + {selected ? : null} + +
+
+ {title} + {trailing} +
+

{description}

+
+
+ ) +} diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx index d66a9144caf..ca7e13f5b14 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx @@ -78,12 +78,48 @@ describe('MobilePairingSetupSection', () => { expect(screen.getByText(/must be able to reach this address/i)).toBeVisible() }) - it('explains the direct address when Orca Relay is selected', () => { - renderSection({ connectionMode: 'automatic' }) - expect(screen.getByText('This computer’s address')).toBeVisible() - expect(screen.getByRole('combobox')).toBeVisible() - expect(screen.getByText(/faster direct path when nearby/i)).toBeVisible() + it('demotes this computer’s address to a disclosure when Orca Relay is selected', async () => { + const { user } = renderSection({ + connectionMode: 'automatic', + selectedAddress: undefined + }) + expect(screen.queryByText('This computer’s address')).toBeNull() + expect(screen.queryByRole('combobox')).toBeNull() expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeEnabled() + + // Relay still advertises a LAN endpoint, so the picker must stay reachable. + await user.click(screen.getByRole('button', { name: /Also use a faster local path/i })) + expect(screen.getByRole('combobox')).toBeVisible() + expect(screen.getByText(/faster than Relay/i)).toBeVisible() + }) + + it('opens the Relay address disclosure when a settings search targets it', () => { + renderSection({ + connectionMode: 'automatic', + addressDisclosureForcedOpen: true + }) + expect(screen.getByRole('combobox')).toBeVisible() + // Why: a trigger here could not collapse the pinned-open picker, so it would + // be a dead control advertising aria-expanded it does not own. + expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull() + }) + + it('never hides a custom address behind the Relay disclosure', () => { + const address = 'host.example:6768' + renderSection({ + connectionMode: 'automatic', + customAddresses: [address], + selectedAddress: address, + selectedAddressIsCustom: true + }) + expect(screen.getByRole('combobox')).toHaveTextContent(address) + expect(screen.queryByRole('button', { name: /Also use a faster local path/i })).toBeNull() + }) + + it('disables generate on LAN when no advertise address is selected', () => { + renderSection({ connectionMode: 'local-only', selectedAddress: undefined }) + expect(screen.getByText('This computer’s address')).toBeVisible() + expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeDisabled() }) it('can move retry recovery into the persistent failure notice', () => { diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx index 8367b49fdbe..c398e199dee 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -1,7 +1,9 @@ -import type { ReactNode } from 'react' -import { Loader2, QrCode, RefreshCw } from 'lucide-react' +import { useState, type ReactNode } from 'react' +import { ChevronDown, Loader2, QrCode, RefreshCw } from 'lucide-react' import { Button } from '../ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { NetworkInterfacePicker } from '../mobile/NetworkInterfacePicker' import type { MobileNetworkInterface } from './mobile-network-interface-selection' @@ -11,6 +13,8 @@ type MobilePairingSetupSectionProps = { connectionMode: MobilePairingConnectionMode /** False when Anywhere is selected but Relay cannot be committed yet. */ canGenerate?: boolean + /** Reveals the Relay address disclosure when a settings search matches it. */ + addressDisclosureForcedOpen?: boolean connectionPathControl: ReactNode networkInterfaces: MobileNetworkInterface[] customAddresses: readonly string[] @@ -30,6 +34,7 @@ type MobilePairingSetupSectionProps = { export function MobilePairingSetupSection({ connectionMode, canGenerate = true, + addressDisclosureForcedOpen = false, connectionPathControl, networkInterfaces, customAddresses, @@ -46,7 +51,72 @@ export function MobilePairingSetupSection({ onGenerateQr }: MobilePairingSetupSectionProps): React.JSX.Element { const usingRelay = connectionMode === 'automatic' - const generateDisabled = loading || !selectedAddress || !canGenerate + const [addressDisclosureOpen, setAddressDisclosureOpen] = useState(false) + // A search hit or a custom address pins the picker open: render it outright + // rather than behind a trigger that could not collapse it anyway. + const addressDisclosurePinned = addressDisclosureForcedOpen || selectedAddressIsCustom + // Relay offers still carry a LAN endpoint for the direct fast path, but main + // substitutes its own default when the renderer has not resolved one yet. + // LAN has no such fallback, so it needs an explicit reachable host. + const generateDisabled = loading || !canGenerate || (!usingRelay && !selectedAddress) + // "Also use…" frames this as additive under Relay, not a second connection mode. + // The phone races both paths and direct wins ties when nearby. + const relayAddressLabel = translate( + 'auto.components.settings.MobilePairingSetupSection.step2RelayDisclosure', + 'Also use a faster local path' + ) + + const addressControls = ( +
+
+ + + + + + + {translate( + 'auto.components.settings.MobilePairingSetupSection.refresh', + 'Refresh network interfaces' + )} + + +
+

+ {usingRelay + ? translate( + 'auto.components.settings.MobilePairingSetupSection.step2RelayDescription', + 'Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away.' + ) + : translate( + 'auto.components.settings.MobilePairingSetupSection.step2LocalDescription', + 'The phone must be able to reach this address on Tailscale or Wi‑Fi.' + )} +

+
+ ) return (
@@ -69,61 +139,50 @@ export function MobilePairingSetupSection({ {connectionPathControl}
-
-

- {translate( - 'auto.components.settings.MobilePairingSetupSection.step2Title', - 'This computer’s address' - )} -

-
- - - - - - - {translate( - 'auto.components.settings.MobilePairingSetupSection.refresh', - 'Refresh network interfaces' - )} - - + {usingRelay && addressDisclosurePinned ? ( +
+

{relayAddressLabel}

+
+ {addressControls} +
-

- {usingRelay - ? translate( - 'auto.components.settings.MobilePairingSetupSection.step2RelayDescription', - 'Used for a faster direct path when nearby. Relay covers remote access.' - ) - : translate( - 'auto.components.settings.MobilePairingSetupSection.step2LocalDescription', - 'The phone must be able to reach this address on Tailscale or Wi‑Fi.' - )} -

-
+ ) : usingRelay ? ( + // Why: Relay makes the address optional, not irrelevant — demote it to a + // disclosure so the direct fast path stays reachable without clutter. + + + + + +
+ {addressControls} +
+
+
+ ) : ( +
+

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.step2Title', + 'This computer’s address' + )} +

+ {addressControls} +
+ )} {showGenerateAction ? (
diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 948cc96615d..1695263a740 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -23,6 +23,7 @@ type PairedDevicesProps = { type StoreState = { orcaProfileAuthStatus: { state: 'connected' | 'local' } + settingsSearchQuery: string settings: { mobileAutoRestoreFitMs: number | null mobilePairingConnectionMode?: MobilePairingConnectionMode @@ -155,7 +156,11 @@ vi.mock('./MobilePairedDevicesSection', () => ({ } })) vi.mock('./MobileAutoRestoreFitSection', () => ({ MobileAutoRestoreFitSection: () =>
})) -vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () =>
})) +vi.mock('../mobile/WindowsFirewallNotice', () => ({ + WindowsFirewallNotice: (props: { usingRelay?: boolean }) => ( +
{String(props.usingRelay)}
+ ) +})) import { MobilePane } from './MobilePane' @@ -180,6 +185,7 @@ describe('MobilePane pairing connection mode', () => { updateSettings.mockReset().mockResolvedValue(undefined) mocks.holder.state = { orcaProfileAuthStatus: { state: 'connected' }, + settingsSearchQuery: '', settings: { mobileAutoRestoreFitMs: null }, updateSettings, recordFeatureInteraction: vi.fn() @@ -475,6 +481,17 @@ describe('MobilePane pairing connection mode', () => { expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr') }) + it('keeps the firewall notice on Relay, which still uses the direct LAN path', async () => { + const user = userEvent.setup() + render() + + expect(screen.getByTestId('mode')).toHaveTextContent('automatic') + expect(screen.getByTestId('firewall-notice')).toHaveTextContent('true') + + await user.click(screen.getByRole('button', { name: 'choose-local' })) + expect(screen.getByTestId('firewall-notice')).toHaveTextContent('false') + }) + it('keeps the current pairing code when only custom address intent changes', async () => { const address = '100.126.117.25:6768' mocks.holder.state.settings = { @@ -777,6 +794,7 @@ describe('MobilePane', () => { mocks.updateSettings.mockReset().mockResolvedValue(undefined) mocks.holder.state = { orcaProfileAuthStatus: { state: 'connected' }, + settingsSearchQuery: '', settings: { mobileAutoRestoreFitMs: null }, updateSettings: mocks.updateSettings, recordFeatureInteraction: vi.fn() diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 00fb7f34399..8dafdf21cef 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -24,6 +24,7 @@ import { import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' import { useMobilePairingConnectionMode } from '../mobile/use-mobile-pairing-connection-mode' import { useMobilePairingAddressPreference } from '../mobile/use-mobile-pairing-address-preference' +import { shouldOpenMobilePairingAddress } from './mobile-pane-search' export { getMobilePaneSearchEntries } from './mobile-pane-search' export function MobilePane(): React.JSX.Element { @@ -41,6 +42,7 @@ export function MobilePane(): React.JSX.Element { const [codeCopied, setCodeCopied] = useState(false) const [deviceCountAtQr, setDeviceCountAtQr] = useState(null) const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') + const settingsSearchQuery = useAppStore((state) => state.settingsSearchQuery) const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode() const [rotateNextQr, setRotateNextQr] = useState(false) const codeCopiedResetTimerRef = useRef(null) @@ -386,6 +388,7 @@ export function MobilePane(): React.JSX.Element { - + Promise + recordFeatureInteraction: () => void +} + +const mocks = vi.hoisted(() => { + const holder: { state: StoreState } = { state: {} as StoreState } + const useAppStore = Object.assign( + (selector: (state: StoreState) => unknown) => selector(holder.state), + { getState: () => holder.state } + ) + return { holder, useAppStore } +}) + +vi.mock('@/store', () => ({ useAppStore: mocks.useAppStore })) +vi.mock('../../store', () => ({ useAppStore: mocks.useAppStore })) +// `i18n` is read by the localized search catalogs the pane consults to decide +// whether a query targets the address picker. +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback, + i18n: { language: 'en' } +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +vi.mock('./mobile-pairing-device-polling', () => ({ useMobilePairingDevicePolling: vi.fn() })) +vi.mock('./MobilePairingSetupSection', () => ({ + MobilePairingSetupSection: (props: { addressDisclosureForcedOpen?: boolean }) => ( +
{String(props.addressDisclosureForcedOpen)}
+ ) +})) +vi.mock('./MobilePairingConnectionOptions', () => ({ + MobilePairingConnectionOptions: () =>
+})) +vi.mock('./MobilePairingQrSection', () => ({ MobilePairingQrSection: () =>
})) +vi.mock('./MobilePairedDevicesSection', () => ({ MobilePairedDevicesSection: () =>
})) +vi.mock('./MobileAutoRestoreFitSection', () => ({ MobileAutoRestoreFitSection: () =>
})) +vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () =>
})) + +import { MobilePane } from './MobilePane' + +describe('MobilePane address disclosure search wiring', () => { + beforeEach(() => { + mocks.holder.state = { + orcaProfileAuthStatus: { state: 'connected' }, + settingsSearchQuery: '', + settings: { mobileAutoRestoreFitMs: null }, + updateSettings: vi.fn().mockResolvedValue(undefined), + recordFeatureInteraction: vi.fn() + } + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + getPairingQR: vi.fn(), + listDevices: vi.fn().mockResolvedValue({ devices: [] }), + listNetworkInterfaces: vi.fn().mockResolvedValue({ interfaces: [] }), + revokeDevice: vi.fn() + } + } + }) + }) + + afterEach(() => cleanup()) + + // Why: an empty query matches every catalog entry, so a missing blank guard + // would spring the disclosure open the moment Settings mounts. + it.each([ + ['', 'false'], + ['revoke', 'false'], + ['tailscale', 'true'], + ['Network Interface', 'true'] + ])('forces the disclosure open for %j: %s', (query, forced) => { + mocks.holder.state.settingsSearchQuery = query + render() + expect(screen.getByTestId('address-forced-open')).toHaveTextContent(forced) + }) +}) diff --git a/src/renderer/src/components/settings/mobile-pane-search.test.ts b/src/renderer/src/components/settings/mobile-pane-search.test.ts new file mode 100644 index 00000000000..fb909663e9f --- /dev/null +++ b/src/renderer/src/components/settings/mobile-pane-search.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' + +import { getMobilePaneSearchEntries, shouldOpenMobilePairingAddress } from './mobile-pane-search' + +describe('getMobilePaneSearchEntries', () => { + // Why: the network entries were split into their own catalog and spliced back + // in. Search ranking breaks ties by index, so a reorder silently reranks rows. + it('keeps Network Interface in its original catalog position', () => { + expect(getMobilePaneSearchEntries().map((entry) => entry.title)).toEqual([ + 'Mobile Pairing', + 'Connected Devices', + 'Network Interface', + 'When you leave the mobile app' + ]) + }) +}) + +describe('shouldOpenMobilePairingAddress', () => { + it('stays closed for an empty or blank query', () => { + // Why: an empty query scores as a match for every entry, so the disclosure + // would spring open the moment Settings mounts without a search. + expect(shouldOpenMobilePairingAddress('')).toBe(false) + expect(shouldOpenMobilePairingAddress(' ')).toBe(false) + }) + + it('opens for queries that target the address picker', () => { + expect(shouldOpenMobilePairingAddress('Network Interface')).toBe(true) + expect(shouldOpenMobilePairingAddress('tailscale')).toBe(true) + expect(shouldOpenMobilePairingAddress(' LAN ')).toBe(true) + }) + + it('stays closed for queries aimed at other rows on the pane', () => { + expect(shouldOpenMobilePairingAddress('qr')).toBe(false) + expect(shouldOpenMobilePairingAddress('revoke')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/settings/mobile-pane-search.ts b/src/renderer/src/components/settings/mobile-pane-search.ts index 669a22a5ce9..576b0f86f8d 100644 --- a/src/renderer/src/components/settings/mobile-pane-search.ts +++ b/src/renderer/src/components/settings/mobile-pane-search.ts @@ -1,43 +1,9 @@ import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { matchesSettingsSearch, normalizeSettingsSearchQuery } from './settings-search' -export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [ - { - title: translate('auto.components.settings.mobile.pane.search.d49925710a', 'Mobile Pairing'), - description: translate( - 'auto.components.settings.mobile.pane.search.7fb728fb2b', - 'Pair a mobile device by scanning a QR code.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.3c1807a81a', 'qr'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.4a0c826f3d', 'code'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.e518cbd61c', 'pair'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.ad08035c5f', 'phone'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.2128a21096', 'scan') - ] - }, - { - title: translate('auto.components.settings.mobile.pane.search.9d3a9397ba', 'Connected Devices'), - description: translate( - 'auto.components.settings.mobile.pane.search.13419718b3', - 'Manage paired mobile devices.' - ), - keywords: [ - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), - ...translateSearchKeyword( - 'auto.components.settings.mobile.pane.search.82783d9b71', - 'devices' - ), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.905c65a308', 'revoke'), - ...translateSearchKeyword('auto.components.settings.mobile.pane.search.5e8fda4d7f', 'paired'), - ...translateSearchKeyword( - 'auto.components.settings.mobile.pane.search.7d01f93ec0', - 'connected' - ) - ] - }, +const getNetworkInterfaceSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.mobile.pane.search.d96c315227', 'Network Interface'), description: translate( @@ -75,7 +41,54 @@ export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [ ...translateSearchKeyword('auto.components.settings.mobile.pane.search.70f505f3c3', 'lan'), ...translateSearchKeyword('auto.components.settings.mobile.pane.search.126afc5dbd', 'remote') ] + } +]) + +/** Reveal the Relay address disclosure when a search targets the picker. */ +export function shouldOpenMobilePairingAddress(searchQuery: string): boolean { + return ( + normalizeSettingsSearchQuery(searchQuery) !== '' && + matchesSettingsSearch(searchQuery, getNetworkInterfaceSearchEntries()) + ) +} + +export const getMobilePaneSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.mobile.pane.search.d49925710a', 'Mobile Pairing'), + description: translate( + 'auto.components.settings.mobile.pane.search.7fb728fb2b', + 'Pair a mobile device by scanning a QR code.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.3c1807a81a', 'qr'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.4a0c826f3d', 'code'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.e518cbd61c', 'pair'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.ad08035c5f', 'phone'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.2128a21096', 'scan') + ] }, + { + title: translate('auto.components.settings.mobile.pane.search.9d3a9397ba', 'Connected Devices'), + description: translate( + 'auto.components.settings.mobile.pane.search.13419718b3', + 'Manage paired mobile devices.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.6db86f445f', 'mobile'), + ...translateSearchKeyword( + 'auto.components.settings.mobile.pane.search.82783d9b71', + 'devices' + ), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.905c65a308', 'revoke'), + ...translateSearchKeyword('auto.components.settings.mobile.pane.search.5e8fda4d7f', 'paired'), + ...translateSearchKeyword( + 'auto.components.settings.mobile.pane.search.7d01f93ec0', + 'connected' + ) + ] + }, + ...getNetworkInterfaceSearchEntries(), { title: translate( 'auto.components.settings.mobile.pane.search.1e711aca11', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9a4dc1498f9..a0c23b47814 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9791,13 +9791,13 @@ "unavailable": "Unavailable", "pathGroup": "How the phone reaches this computer", "anywhereTitle": "Orca Relay", - "anywhereDescription": "Phone can be on cellular or any Wi‑Fi. Sign-in required.", - "signInRequired": "Sign in to use Orca Mobile Relay.", + "anywhereDescription": "Phone can be on cellular or any Wi‑Fi. Sign-in required for Relay only.", + "signInRequired": "Relay only — LAN does not need an account.", "relayUnavailable": "Orca Relay isn’t available in this build. Use LAN.", - "signIn": "Sign in", - "signInAgain": "Sign in again", + "signIn": "Sign in for Relay", + "signInAgain": "Sign in again for Relay", "localTitle": "LAN", - "localDescription": "Phone must be on this Wi‑Fi or connected through Tailscale. No sign-in required.", + "localDescription": "Phone must be on this Wi‑Fi or connected through Tailscale. No account needed.", "retrying": "Retrying" }, "MobilePairingSetupSection": { @@ -9805,11 +9805,12 @@ "overview": "Generate a QR code, then scan it in Orca Mobile under Pair Desktop.", "step1Title": "Connection", "step2Title": "This computer’s address", - "step2RelayDescription": "Used for a faster direct path when nearby. Relay covers remote access.", - "step2LocalDescription": "The phone must be able to reach this address on Wi‑Fi or Tailscale.", + "step2LocalDescription": "The phone must be able to reach this address on Tailscale or Wi‑Fi.", "regenerate": "Regenerate QR code", "generate": "Generate QR code", - "refresh": "Refresh network interfaces" + "refresh": "Refresh network interfaces", + "step2RelayDescription": "Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away.", + "step2RelayDisclosure": "Also use a faster local path" }, "MobileRelayBetaAvailability": { "about": "About the Orca Relay beta", @@ -12030,7 +12031,9 @@ "pairingCodeReady": "Pairing code ready", "pairThisMac": "Pair this Mac.", "pairThisPc": "Pair this PC.", - "pairThisComputer": "Pair this computer." + "pairThisComputer": "Pair this computer.", + "directAddressDisclosure": "Also use a faster local path", + "directAddressHint": "Optional. Pick the Wi‑Fi or Tailscale address your phone should use when nearby — usually faster than Relay. Relay still works when you’re away." }, "MobilePage": { "e17393c6a3": "Phone preview", @@ -12177,7 +12180,8 @@ "repair-unverified": "Windows Firewall access could not be verified", "blocked-title": "Windows may be blocking Orca Mobile", "blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.", - "repair": "Repair firewall access" + "repair": "Repair firewall access", + "relay-note": "Pairing still works over Orca Relay — allowing this only adds the faster local connection." }, "MobileRelayMintFailureNotice": { "retryingTitle": "Retrying Orca Relay…", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index f72d53c4395..60c1f6a7a61 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9577,14 +9577,14 @@ "available": "Disponible", "reconnecting": "Reconectando", "unavailable": "No disponible", - "signIn": "Iniciar sesión", - "localDescription": "El teléfono debe estar en esta Wi‑Fi o en tu Tailscale. No requiere inicio de sesión.", + "signIn": "Iniciar sesión para Relay", + "localDescription": "El teléfono debe estar en esta Wi‑Fi o conectado por Tailscale. No se necesita cuenta.", "pathGroup": "Cómo llega el teléfono a este ordenador", "anywhereTitle": "Orca Relay", - "anywhereDescription": "El teléfono puede estar en datos móviles o cualquier Wi‑Fi. Se requiere inicio de sesión.", - "signInRequired": "Inicia sesión para usar Orca Mobile Relay.", + "anywhereDescription": "El teléfono puede estar en datos móviles o cualquier Wi‑Fi. El inicio de sesión solo es necesario para Relay.", + "signInRequired": "Solo Relay — LAN no necesita una cuenta.", "relayUnavailable": "Orca Relay no está disponible en esta compilación. Usa LAN.", - "signInAgain": "Iniciar sesión de nuevo", + "signInAgain": "Volver a iniciar sesión para Relay", "localTitle": "LAN" }, "MobilePairingSetupSection": { @@ -9595,8 +9595,9 @@ "overview": "Genera un código QR, luego escanéalo en Orca Mobile en Pair Desktop.", "step1Title": "Método de conexión", "step2Title": "Dirección de este ordenador", - "step2RelayDescription": "Se usa para una ruta directa más rápida cuando está cerca. Relay cubre el acceso remoto.", - "step2LocalDescription": "El teléfono debe poder alcanzar esta dirección por Wi‑Fi o Tailscale." + "step2LocalDescription": "El teléfono debe poder alcanzar esta dirección por Tailscale o Wi‑Fi.", + "step2RelayDescription": "Opcional. Elige la dirección Wi‑Fi o Tailscale que el teléfono usará cuando esté cerca — suele ser más rápido que Relay. Relay sigue funcionando cuando estás fuera.", + "step2RelayDisclosure": "También usar una ruta local más rápida" }, "MobileRelayBetaAvailability": { "about": "Acerca de la beta de Orca Relay", @@ -11756,7 +11757,9 @@ "stable": "Estable" }, "relayDegradedNotice": "No se pudo contactar con Relay: este código solo funciona en tu LAN o Tailscale.", - "pairingQrError": "Este código de emparejamiento no se pudo representar como código QR. Cópialo en Orca Mobile." + "pairingQrError": "Este código de emparejamiento no se pudo representar como código QR. Cópialo en Orca Mobile.", + "directAddressDisclosure": "También usar una ruta local más rápida", + "directAddressHint": "Opcional. Elige la dirección Wi‑Fi o Tailscale que el teléfono usará cuando esté cerca — suele ser más rápido que Relay. Relay sigue funcionando cuando estás fuera." }, "MobilePage": { "e17393c6a3": "Vista previa del teléfono", @@ -11899,7 +11902,8 @@ "repair-unverified": "Windows Firewall access could not be verified", "blocked-title": "Windows may be blocking Orca Mobile", "blocked-description": "An existing inbound Block rule can override the pairing exception. Repair removes conflicting TCP rules for this Orca app, then allows port {{port}} on Private networks.", - "repair": "Repair firewall access" + "repair": "Repair firewall access", + "relay-note": "El emparejamiento sigue funcionando por Orca Relay: permitirlo solo añade la conexión local más rápida." } }, "gitlab": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e331482611b..e9099533abc 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9577,14 +9577,14 @@ "available": "利用可能", "reconnecting": "再接続中", "unavailable": "利用不可", - "signIn": "サインイン", - "localDescription": "スマートフォンがこの Wi‑Fi または Tailscale に接続されている必要があります。サインインは不要です。", + "signIn": "Relay にサインイン", + "localDescription": "スマートフォンがこの Wi‑Fi または Tailscale に接続されている必要があります。アカウントは不要です。", "pathGroup": "スマートフォンがこのコンピューターに到達する方法", "anywhereTitle": "Orca Relay", - "anywhereDescription": "スマートフォンはモバイル回線または Wi‑Fi から接続できます。サインインが必要です。", - "signInRequired": "Orca Mobile Relay を使用するにはサインインしてください。", + "anywhereDescription": "スマートフォンはモバイル回線または任意の Wi‑Fi から接続できます。サインインが必要なのは Relay のみです。", + "signInRequired": "Relay のみ — LAN にはアカウントは不要です。", "relayUnavailable": "このビルドでは Orca Relay を利用できません。LAN をご利用ください。", - "signInAgain": "再サインイン", + "signInAgain": "Relay に再サインイン", "localTitle": "LAN" }, "MobilePairingSetupSection": { @@ -9595,8 +9595,9 @@ "overview": "QR コードを生成し、Orca Mobile の Pair Desktop でスキャンしてください。", "step1Title": "接続方法", "step2Title": "このコンピューターのアドレス", - "step2RelayDescription": "近くでは高速な直接接続に使用されます。Relay はリモートアクセスに対応します。", - "step2LocalDescription": "スマートフォンが Wi‑Fi または Tailscale でこのアドレスに到達できる必要があります。" + "step2LocalDescription": "スマートフォンが Tailscale または Wi‑Fi でこのアドレスに到達できる必要があります。", + "step2RelayDescription": "任意。近く(同じ Wi‑Fi または Tailscale)にいるときにスマホが使うアドレスを選びます。通常は Relay より高速です。外出中は引き続き Relay が使われます。", + "step2RelayDisclosure": "より速いローカル経路も使う" }, "MobileRelayBetaAvailability": { "about": "Orca Relay ベータについて", @@ -11756,7 +11757,9 @@ "stable": "安定版" }, "relayDegradedNotice": "Relay に接続できませんでした — このコードは LAN または Tailscale でのみ動作します。", - "pairingQrError": "このペアリングコードを QR コードとして表示できませんでした。代わりに Orca Mobile にコピーしてください。" + "pairingQrError": "このペアリングコードを QR コードとして表示できませんでした。代わりに Orca Mobile にコピーしてください。", + "directAddressDisclosure": "より速いローカル経路も使う", + "directAddressHint": "任意。近く(同じ Wi‑Fi または Tailscale)にいるときにスマホが使うアドレスを選びます。通常は Relay より高速です。外出中は引き続き Relay が使われます。" }, "MobilePage": { "e17393c6a3": "スマートフォンプレビュー", @@ -11899,7 +11902,8 @@ "repair-unverified": "Windows Firewall access could not be verified", "blocked-title": "WindowsがOrcaモバイルをブロックしている可能性があります", "blocked-description": "既存のインバウンドブロックルールがペアリング例外を上書きする可能性があります。修復はこのOrcaアプリの競合するTCPルールを削除し、プライベートネットワーク上のポート{{port}}を許可します。", - "repair": "ファイアウォールアクセスを修復" + "repair": "ファイアウォールアクセスを修復", + "relay-note": "ペアリングは Orca Relay 経由で引き続き機能します。許可すると、より高速なローカル接続が追加されるだけです。" } }, "gitlab": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 82852ed46f9..5c3ab64d74e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9577,14 +9577,14 @@ "available": "사용 가능", "reconnecting": "다시 연결 중", "unavailable": "사용할 수 없음", - "signIn": "로그인", - "localDescription": "휴대폰이 이 Wi‑Fi 또는 Tailscale에 연결되어 있어야 합니다. 로그인이 필요하지 않습니다.", + "signIn": "Relay용 로그인", + "localDescription": "휴대폰이 이 Wi‑Fi에 있거나 Tailscale로 연결되어 있어야 합니다. 계정이 필요하지 않습니다.", "pathGroup": "휴대폰이 이 컴퓨터에 연결되는 방식", "anywhereTitle": "Orca Relay", - "anywhereDescription": "휴대폰이 모바일 네트워크 또는 모든 Wi‑Fi에서 연결 가능합니다. 로그인이 필요합니다.", - "signInRequired": "Orca Mobile Relay를 사용하려면 로그인하세요.", + "anywhereDescription": "휴대폰이 모바일 네트워크 또는 모든 Wi‑Fi에서 연결할 수 있습니다. 로그인은 Relay에만 필요합니다.", + "signInRequired": "Relay만 해당 — LAN에는 계정이 필요하지 않습니다.", "relayUnavailable": "이 빌드에서는 Orca Relay를 사용할 수 없습니다. LAN을 사용하세요.", - "signInAgain": "다시 로그인", + "signInAgain": "Relay용 다시 로그인", "localTitle": "LAN" }, "MobilePairingSetupSection": { @@ -9595,8 +9595,9 @@ "overview": "QR 코드를 생성한 후 Orca Mobile의 Pair Desktop에서 스캔하세요.", "step1Title": "연결 방식", "step2Title": "이 컴퓨터의 주소", - "step2RelayDescription": "가까운 곳에서는 빠른 직접 연결에 사용됩니다. 먼 거리에서는 Relay가 원격 액세스를 제공합니다.", - "step2LocalDescription": "휴대폰이 Wi‑Fi 또는 Tailscale을 통해 이 주소에 접근할 수 있어야 합니다." + "step2LocalDescription": "휴대폰이 Tailscale 또는 Wi‑Fi를 통해 이 주소에 접근할 수 있어야 합니다.", + "step2RelayDescription": "선택 사항. 근처에 있을 때(같은 Wi‑Fi 또는 Tailscale) 휴대폰이 사용할 주소를 고르세요. 보통 Relay보다 빠릅니다. 외부에 있을 때는 여전히 Relay를 사용합니다.", + "step2RelayDisclosure": "더 빠른 로컬 경로도 사용" }, "MobileRelayBetaAvailability": { "about": "Orca Relay 베타 정보", @@ -11756,7 +11757,9 @@ "stable": "안정 버전" }, "relayDegradedNotice": "Relay에 연결할 수 없습니다 — 이 코드는 LAN 또는 Tailscale에서만 작동합니다.", - "pairingQrError": "이 페어링 코드를 QR 코드로 표시할 수 없습니다. 대신 Orca Mobile에 복사하세요." + "pairingQrError": "이 페어링 코드를 QR 코드로 표시할 수 없습니다. 대신 Orca Mobile에 복사하세요.", + "directAddressDisclosure": "더 빠른 로컬 경로도 사용", + "directAddressHint": "선택 사항. 근처에 있을 때(같은 Wi‑Fi 또는 Tailscale) 휴대폰이 사용할 주소를 고르세요. 보통 Relay보다 빠릅니다. 외부에 있을 때는 여전히 Relay를 사용합니다." }, "MobilePage": { "e17393c6a3": "휴대폰 미리보기", @@ -11899,7 +11902,8 @@ "repair-unverified": "Windows 방화벽 액세스를 확인할 수 없습니다", "blocked-title": "Windows가 Orca Mobile을 차단하고 있을 수 있습니다", "blocked-description": "기존 인바운드 차단 규칙이 페어링 예외보다 우선할 수 있습니다. 복구를 실행하면 이 Orca 앱의 충돌하는 TCP 규칙을 제거한 다음 개인 네트워크에서 포트 {{port}}을(를) 허용합니다.", - "repair": "방화벽 액세스 복구" + "repair": "방화벽 액세스 복구", + "relay-note": "페어링은 Orca Relay를 통해 계속 작동합니다. 허용하면 더 빠른 로컬 연결이 추가될 뿐입니다." } }, "gitlab": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1dacaaaee2f..6671acc8ff6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9589,14 +9589,14 @@ "available": "可用", "reconnecting": "正在重新连接", "unavailable": "不可用", - "signIn": "登录", - "localDescription": "手机必须连接此 Wi‑Fi 或您的 Tailscale。无需登录。", + "signIn": "登录以使用 Relay", + "localDescription": "手机必须连接此 Wi‑Fi 或通过 Tailscale。无需账号。", "pathGroup": "手机访问此电脑的方式", "anywhereTitle": "Orca Relay", - "anywhereDescription": "手机可通过蜂窝网络或任意 Wi‑Fi 连接。需要登录。", - "signInRequired": "请登录以使用 Orca Mobile Relay。", + "anywhereDescription": "手机可通过蜂窝网络或任意 Wi‑Fi 连接。仅 Relay 需要登录。", + "signInRequired": "仅 Relay 需要 — 局域网无需账号。", "relayUnavailable": "此版本不支持 Orca Relay。请使用局域网。", - "signInAgain": "重新登录", + "signInAgain": "重新登录以使用 Relay", "localTitle": "局域网" }, "MobilePairingSetupSection": { @@ -9607,8 +9607,9 @@ "overview": "生成二维码,然后在 Orca Mobile 的 Pair Desktop 中扫描。", "step1Title": "连接方式", "step2Title": "此电脑的地址", - "step2RelayDescription": "距离较近时用于快速直接连接。Relay 覆盖远程访问。", - "step2LocalDescription": "手机必须能通过 Wi‑Fi 或 Tailscale 访问此地址。" + "step2LocalDescription": "手机必须能通过 Tailscale 或 Wi‑Fi 访问此地址。", + "step2RelayDescription": "可选。选择手机在附近(同一 Wi‑Fi 或 Tailscale)时使用的地址,通常比 Relay 更快。外出时仍走 Relay。", + "step2RelayDisclosure": "也可使用更快的本地连接" }, "MobileRelayBetaAvailability": { "about": "关于 Orca Relay 测试版", @@ -11776,7 +11777,9 @@ "stable": "稳定版" }, "relayDegradedNotice": "无法连接 Relay — 此二维码仅在局域网或 Tailscale 内可用。", - "pairingQrError": "无法将此配对码呈现为二维码。请改为将其复制到 Orca Mobile。" + "pairingQrError": "无法将此配对码呈现为二维码。请改为将其复制到 Orca Mobile。", + "directAddressDisclosure": "也可使用更快的本地连接", + "directAddressHint": "可选。选择手机在附近(同一 Wi‑Fi 或 Tailscale)时使用的地址,通常比 Relay 更快。外出时仍走 Relay。" }, "MobilePage": { "e17393c6a3": "手机预览", @@ -11919,7 +11922,8 @@ "repair-unverified": "无法验证 Windows 防火墙访问权限", "blocked-title": "Windows 可能正在阻止 Orca Mobile", "blocked-description": "现有的入站阻止规则可能会覆盖配对例外。修复将移除此 Orca 应用的冲突 TCP 规则,然后在专用网络上允许端口 {{port}}。", - "repair": "修复防火墙访问权限" + "repair": "修复防火墙访问权限", + "relay-note": "配对仍可通过 Orca Relay 进行 — 允许后只是额外获得更快的本地连接。" } }, "gitlab": { From b5ad2ac16351ce614d1be843bed0ac814a017110 Mon Sep 17 00:00:00 2001 From: dcieslak19973 Date: Thu, 6 Aug 2026 03:29:19 -0500 Subject: [PATCH 46/46] fix(ai-vault): group OMP task subagent transcripts under their parent session (#12803) * fix(ai-vault): group OMP task subagent transcripts under their parent session OMP persists task-child transcripts inside the parent session's same-named artifact directory (_/), and discovery scanned them as ordinary top-level sessions - a coordinator's history drowned under its own workers. Extend the existing Claude subagent model to OMP, classifying purely by the artifact-dir layout (never by a transcript's parentSession field, which also describes non-task lineage): - prune artifact dirs from the top-level scan (name-pattern predicate) - count direct-child transcripts onto the parent row (local readdir; remote walks partition their listing instead, mirroring Claude's SSH posture) - list children on demand via the existing listSubagentSessions IPC, titled by their coordinator-given task label and linked to the layout-derived parent id - refresh the count on zero-turn cache reuse, matching Claude - extract session-scanner-roots.ts so the renderer-supplied-path allowlist for both agents lives in one module Fixes #9330 * review: harden OMP subagent classification and cover its uncovered branches Prune predicate now skips depth 0 (the workspace dir), so a workspace whose name happens to look like a session stem keeps its sessions. Drop degenerate OMP roots in ompSessionsRootDirs: OMP_CODING_AGENT_DIR='/' normalizes to '', which resolve()s to the process cwd and would have allowlisted it for the renderer-supplied subagent path. Rename session-scanner-omp-subagents.ts to -omp-subagent-transcripts.ts so it mirrors Claude's transcripts/lister split by role rather than inverting it. Correct two comments that asserted things the codebase contradicts: OMP task children do carry their own sessionId and would resume by path (OMP's own picker globs `*/*.jsonl`, so it never offers them either), and workspace dir names are not uniformly dash-prefixed. Cover branches the change added with no test: the remote/SSH partition wiring, the IPC `omp` gate and per-agent allowlist, the parse-cache zero-turn recount, and the executionHostId disk-ownership guard. Extract the remote scanner's in-memory provider into a fixtures module to stay under the max-lines cap. * review: note why child rows carry an unrendered grandchild count * review: describe the real OMP grandchild layout in the pattern comment --------- Co-authored-by: Dan Cieslak Co-authored-by: Jinwoo-H --- .../remote-session-scanner-discovery.ts | 5 +- ...mote-session-scanner-omp-subagents.test.ts | 71 +++++++++ .../remote-session-scanner-sources.ts | 12 +- .../remote-session-scanner-test-fixtures.ts | 100 ++++++++++++ .../ai-vault/remote-session-scanner-types.ts | 8 +- .../ai-vault/remote-session-scanner.test.ts | 99 +----------- .../ai-vault/session-scanner-graph-parsers.ts | 7 +- ...ssion-scanner-omp-subagent-listing.test.ts | 81 ++++++++++ .../session-scanner-omp-subagent-listing.ts | 113 +++++++++++++ ...session-scanner-omp-subagent-prune.test.ts | 149 ++++++++++++++++++ ...n-scanner-omp-subagent-transcripts.test.ts | 145 +++++++++++++++++ ...ession-scanner-omp-subagent-transcripts.ts | 119 ++++++++++++++ .../ai-vault/session-scanner-parse-cache.ts | 22 ++- .../ai-vault/session-scanner-roots.test.ts | 24 +++ src/main/ai-vault/session-scanner-roots.ts | 64 ++++++++ .../session-scanner-source-discovery.ts | 63 +++----- .../session-scanner-subagent-transcripts.ts | 14 +- src/main/ipc/ai-vault.test.ts | 58 ++++++- src/main/ipc/ai-vault.ts | 28 ++-- 19 files changed, 1012 insertions(+), 170 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts create mode 100644 src/main/ai-vault/remote-session-scanner-test-fixtures.ts create mode 100644 src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts create mode 100644 src/main/ai-vault/session-scanner-omp-subagent-listing.ts create mode 100644 src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts create mode 100644 src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts create mode 100644 src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts create mode 100644 src/main/ai-vault/session-scanner-roots.test.ts create mode 100644 src/main/ai-vault/session-scanner-roots.ts diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index a309f51077b..91f21eab720 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -3,7 +3,6 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' @@ -25,9 +24,7 @@ export async function discoverRemoteSourceCandidates(args: { const walked = args.source.fixedChildFileSegments ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) : await walkRemoteSessionFiles(args.source, args.context, args.issues) - const partition = args.source.collectSubagentSiblingCounts - ? partitionSubagentTranscriptPaths(walked) - : null + const partition = args.source.partitionSubagentTranscripts?.(walked) ?? null const paths = partition ? partition.sessionFilePaths : walked const files = await mapRemoteScanBatches( paths, diff --git a/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts b/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts new file mode 100644 index 00000000000..8185765e4d2 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' + +describe('scanRemoteAiVaultSessions OMP subagent partitioning', () => { + it('excludes remote OMP artifact-dir transcripts and counts them on the parent', async () => { + const stem = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' + const workspaceDir = '/home/ada/.omp/agent/sessions/home-app-85dfa2f0' + const provider = new MemoryRemoteProvider() + provider.addFile( + `${workspaceDir}/${stem}.jsonl`, + jsonLines([ + { + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/home/ada/repo', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Delegate some tasks' } + } + ]), + 70 + ) + for (const label of ['AuthAndPreflight', 'BitbucketDcApi']) { + provider.addFile( + `${workspaceDir}/${stem}/${label}.jsonl`, + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:01:00.000Z', + message: { role: 'user', content: `${label} task prompt` } + } + ]), + 71 + ) + } + // A grandchild belongs to its own parent: neither a row nor a count here. + provider.addFile( + `${workspaceDir}/${stem}/AuthAndPreflight/Nested.jsonl`, + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:02:00.000Z', + message: { role: 'user', content: 'Nested task prompt' } + } + ]), + 72 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'omp', + sessionId: 'cccccccc-dddd-4eee-8fff-000000000000', + subagentTranscriptCount: 2, + filePath: `${workspaceDir}/${stem}.jsonl` + }) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index d704c2da671..debdc3881fc 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -12,6 +12,8 @@ import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' import { parseCopilotSessionContent } from './session-scanner-copilot-parser' import { parseCursorSessionContent } from './session-scanner-cursor-parser' import { parseHermesSessionContent } from './session-scanner-hermes-parser' +import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' +import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' @@ -49,7 +51,7 @@ export function remoteSessionSources( // subagent counts instead. Partitioning also prunes the subagent // transcripts themselves, which would otherwise list as phantom // top-level sessions carrying the parent's sessionId. - collectSubagentSiblingCounts: true + partitionSubagentTranscripts: partitionSubagentTranscriptPaths }, remoteAntigravitySource(remoteHome, hostPlatform), source( @@ -92,7 +94,13 @@ export function remoteSessionSources( parseDevinSessionContent ), jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), - jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), + { + ...jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), + // Same posture as Claude above: OMP stores task-subagent transcripts in + // the session's same-named artifact dir; the walk supplies counts and the + // partition keeps the children out of the top-level list (#9330). + partitionSubagentTranscripts: partitionOmpSubagentTranscriptPaths + }, jsonlSource( 'droid', remoteHome, diff --git a/src/main/ai-vault/remote-session-scanner-test-fixtures.ts b/src/main/ai-vault/remote-session-scanner-test-fixtures.ts new file mode 100644 index 00000000000..c3496590168 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-test-fixtures.ts @@ -0,0 +1,100 @@ +import type { DirEntry } from '../../shared/types' +import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' + +// Shared by the remote AI Vault scanner suites: an in-memory filesystem whose +// directory tree is derived from the file paths added to it. +export class MemoryRemoteProvider implements IFilesystemProvider { + private readonly files = new Map() + private readonly readDirErrors = new Map() + private readonly statErrors = new Map() + readonly readDirPaths: string[] = [] + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + failStat(path: string, error: Error): void { + this.statErrors.set(normalize(path), error) + } + + failReadDir(path: string, error: Error): void { + this.readDirErrors.set(normalize(path), error) + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + this.readDirPaths.push(dir) + const readDirError = this.readDirErrors.get(dir) + if (readDirError) { + throw readDirError + } + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) { + continue + } + const relative = path.slice(prefix.length) + if (!relative) { + continue + } + const [name, ...rest] = relative.split('/') + if (!name) { + continue + } + entries.set(name, { + name, + isDirectory: rest.length > 0, + isSymlink: false + }) + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) + } + + async readFile(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { content: file.content, isBinary: false } + } + + async stat(filePath: string): Promise { + const statError = this.statErrors.get(normalize(filePath)) + if (statError) { + throw statError + } + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } + } + + writeFile = unsupported + writeFileBase64 = unsupported + writeFileBase64Chunk = unsupported + deletePath = unsupported + createFile = unsupported + createDir = unsupported + createDirNoClobber = unsupported + rename = unsupported + renameNoClobber = unsupported + copy = unsupported + realpath = async (path: string): Promise => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +async function unsupported(): Promise { + throw new Error('unsupported') +} + +function normalize(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +export function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 58fb255e3cc..8c9774abe14 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -3,6 +3,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { FileWithMtime } from './session-scanner-types' +import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { @@ -36,9 +37,10 @@ export type RemoteSessionSource = { directoryPredicate?: (name: string, depth: number) => boolean // A canonical file directly beneath every top-level session directory. fixedChildFileSegments?: readonly string[] - // Claude layout: count `/subagents/*.jsonl` siblings from the walked - // listing and drop them from candidates instead of indexing them as sessions. - collectSubagentSiblingCounts?: boolean + // Sibling-subagent layouts (Claude `/subagents/`, OMP's same-named + // artifact dir): count subagent transcripts from the walked listing and drop + // them from candidates instead of indexing them as sessions. + partitionSubagentTranscripts?: (paths: readonly string[]) => SubagentTranscriptPartition parse: ( file: FileWithMtime, content: string, diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a4705ee1bfb..462ced9aa72 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -1,104 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { DirEntry } from '../../shared/types' -import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from './remote-session-scanner' - -class MemoryRemoteProvider implements IFilesystemProvider { - private readonly files = new Map() - private readonly readDirErrors = new Map() - private readonly statErrors = new Map() - readonly readDirPaths: string[] = [] - - addFile(path: string, content: string, mtimeMs: number): void { - this.files.set(normalize(path), { content, mtimeMs }) - } - - failStat(path: string, error: Error): void { - this.statErrors.set(normalize(path), error) - } - - failReadDir(path: string, error: Error): void { - this.readDirErrors.set(normalize(path), error) - } - - async readDir(dirPath: string): Promise { - const dir = normalize(dirPath) - this.readDirPaths.push(dir) - const readDirError = this.readDirErrors.get(dir) - if (readDirError) { - throw readDirError - } - const prefix = dir.endsWith('/') ? dir : `${dir}/` - const entries = new Map() - for (const path of this.files.keys()) { - if (!path.startsWith(prefix)) { - continue - } - const relative = path.slice(prefix.length) - if (!relative) { - continue - } - const [name, ...rest] = relative.split('/') - if (!name) { - continue - } - entries.set(name, { - name, - isDirectory: rest.length > 0, - isSymlink: false - }) - } - return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) - } - - async readFile(filePath: string): Promise { - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { content: file.content, isBinary: false } - } - - async stat(filePath: string): Promise { - const statError = this.statErrors.get(normalize(filePath)) - if (statError) { - throw statError - } - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } - } - - writeFile = unsupported - writeFileBase64 = unsupported - writeFileBase64Chunk = unsupported - deletePath = unsupported - createFile = unsupported - createDir = unsupported - createDirNoClobber = unsupported - rename = unsupported - renameNoClobber = unsupported - copy = unsupported - realpath = async (path: string): Promise => path - search = unsupported - listFiles = unsupported - watch = unsupported -} - -async function unsupported(): Promise { - throw new Error('unsupported') -} - -function normalize(path: string): string { - return path.replace(/\\/g, '/').replace(/\/+$/, '') -} - -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' describe('scanRemoteAiVaultSessions', () => { it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index b4d0c171171..8aeb89089fc 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -5,6 +5,7 @@ import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' +import { withOmpSubagentTranscriptCount } from './session-scanner-omp-subagent-transcripts' import type { FileWithMtime, ResumableSessionParseState, @@ -239,10 +240,14 @@ export function createMessageGraphSessionResumeState( agent: MessageGraphAgent, file: FileWithMtime ): ResumableSessionParseState { - return accumulatorFoldResumeState( + const state = accumulatorFoldResumeState( createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }), consumeMessageGraphRecordLine ) + // Why: only OMP materializes task-subagent transcripts beside its sessions + // (in the same-named artifact dir); the row UI shows the count without + // expanding details. Pi/OpenClaw have no such layout — skip the readdir. + return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state } async function parseMessageGraphSessionLines(args: { diff --git a/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts new file mode 100644 index 00000000000..16511e14239 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts @@ -0,0 +1,81 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listOmpSubagentSessions } from './session-scanner-omp-subagent-listing' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' +const PARENT_SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function childTranscript(id: string, timestamp: string, prompt: string): string { + return [ + JSON.stringify({ type: 'session', version: 3, id, cwd: '/repo/app', timestamp }), + JSON.stringify({ type: 'message', timestamp, message: { role: 'user', content: prompt } }) + ].join('\n') +} + +describe('listOmpSubagentSessions', () => { + it('lists artifact-dir transcripts under the parent, titled by task label', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'orca-omp-subagent-list-')) + tempRoots.push(workspace) + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + const artifactDir = join(workspace, SESSION_STEM) + await mkdir(join(artifactDir, 'local'), { recursive: true }) + await writeFile(parentPath, '') + await writeFile( + join(artifactDir, 'AuthAndPreflight.jsonl'), + childTranscript( + 'aaaaaaaa-bbbb-4ccc-8ddd-222222222222', + '2026-05-01T10:01:00.000Z', + 'Map the auth surface' + ) + ) + await writeFile( + join(artifactDir, 'BitbucketDcApi.jsonl'), + childTranscript( + 'aaaaaaaa-bbbb-4ccc-8ddd-333333333333', + '2026-05-01T10:02:00.000Z', + 'Read the DC API spec' + ) + ) + // Artifacts are not transcripts; nested files belong to their own parents. + await writeFile(join(artifactDir, 'notes.md'), 'not a transcript') + await writeFile(join(artifactDir, 'local', 'plan.jsonl'), '{}') + + const result = await listOmpSubagentSessions({ + parentFilePath: parentPath, + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + // Newest first, titled by the coordinator-given task label, each linked to + // the parent derived from the layout (not the child's own session record). + expect( + result.sessions.map((session) => ({ + title: session.title, + parent: session.subagent?.parentSessionId + })) + ).toEqual([ + { title: 'BitbucketDcApi', parent: PARENT_SESSION_ID }, + { title: 'AuthAndPreflight', parent: PARENT_SESSION_ID } + ]) + }) + + it('resolves empty for a session that never delegated', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'orca-omp-subagent-list-')) + tempRoots.push(workspace) + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + await writeFile(parentPath, '') + + await expect( + listOmpSubagentSessions({ parentFilePath: parentPath, platform: 'darwin' }) + ).resolves.toEqual({ sessions: [], issues: [] }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-listing.ts b/src/main/ai-vault/session-scanner-omp-subagent-listing.ts new file mode 100644 index 00000000000..0efcdbdc92c --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-listing.ts @@ -0,0 +1,113 @@ +import { readdir, stat } from 'node:fs/promises' +import { basename, extname, join } from 'node:path' +import type { + AiVaultScanIssue, + AiVaultSession, + AiVaultSubagentListResult +} from '../../shared/ai-vault-types' +import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator' +import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers' +import { + isOmpSubagentTranscriptFileName, + ompArtifactDirFor +} from './session-scanner-omp-subagent-transcripts' +import { errorMessage } from './session-scanner-values' + +// Match the Claude subagent lister's deliberate parse batching: opening every +// read stream at once stalls over WSL UNC paths. +const OMP_SUBAGENT_PARSE_CONCURRENCY = 8 + +/** + * List the task-subagent transcripts of one OMP session, on demand. The main + * scan prunes session artifact directories for speed, so this is the only path + * that reads them — and only when the user expands a session's details. + * (Lives apart from session-scanner-omp-subagent-transcripts.ts so the graph + * parser can import the count decorator without a parser↔lister import cycle — + * the same split as Claude's transcripts/lister pair.) + */ +export async function listOmpSubagentSessions(args: { + parentFilePath: string + platform?: NodeJS.Platform +}): Promise { + const platform = args.platform ?? process.platform + const issues: AiVaultScanIssue[] = [] + const artifactDir = ompArtifactDirFor(args.parentFilePath) + + let entries + try { + entries = await readdir(artifactDir, { withFileTypes: true }) + } catch { + return { sessions: [], issues } + } + + const transcriptNames = entries + .filter((entry) => isOmpSubagentTranscriptFileName(entry.name, entry.isFile())) + .map((entry) => entry.name) + if (transcriptNames.length === 0) { + return { sessions: [], issues } + } + + // Why: the layout fixes the parent's sessionId (_.jsonl → the + // artifact dir); deriving it here keeps a child linking to its parent even + // when the child transcript carries its own distinct session record. + const parentSessionId = sessionIdFromFileName(args.parentFilePath) + const parsed: (AiVaultSession | null)[] = [] + for (let index = 0; index < transcriptNames.length; index += OMP_SUBAGENT_PARSE_CONCURRENCY) { + const batch = transcriptNames.slice(index, index + OMP_SUBAGENT_PARSE_CONCURRENCY) + const batchResults = await Promise.all( + batch.map((name) => + parseOmpSubagentTranscript({ artifactDir, name, parentSessionId, platform, issues }) + ) + ) + parsed.push(...batchResults) + } + + return { + sessions: parsed + .filter((session): session is AiVaultSession => session !== null) + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)), + issues + } +} + +async function parseOmpSubagentTranscript(args: { + artifactDir: string + name: string + parentSessionId: string + platform: NodeJS.Platform + issues: AiVaultScanIssue[] +}): Promise { + const filePath = join(args.artifactDir, args.name) + try { + const fileStat = await stat(filePath) + // The shared OMP parser decorates every parse with an artifact-dir count, so + // a child row carries its own grandchild count. It is accurate but has no + // renderer — subagent rows don't expand — and this lister is local-only, so + // the remote partition never reaches it. + const session = await parseMessageGraphSessionFile( + 'omp', + { path: filePath, mtimeMs: fileStat.mtimeMs, modifiedAt: fileStat.mtime.toISOString() }, + args.platform + ) + if (!session) { + return null + } + return { + ...session, + // Why: OMP names each child transcript after the task's label — the name + // the coordinator gave it. That beats the transcript-derived fallback + // (the raw task prompt). + title: basename(args.name, extname(args.name)), + subagent: { + parentSessionId: args.parentSessionId, + agentType: null, + // OMP parents don't record per-child terminal statuses in a shape this + // lister can attribute; unknown beats a stale guess. + status: null + } + } + } catch (err) { + args.issues.push({ agent: 'omp', path: filePath, message: errorMessage(err) }) + return null + } +} diff --git a/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts new file mode 100644 index 00000000000..7a8314962b7 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts @@ -0,0 +1,149 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' +const SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions OMP subagent pruning', () => { + it('prunes artifact-dir transcripts instead of listing them as sessions', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-prune-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspaceDir = join(roots.ompSessionsDir, 'home-app-85dfa2f0') + const artifactDir = join(workspaceDir, SESSION_STEM) + await mkdir(artifactDir, { recursive: true }) + + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Delegate some tasks' } + } + ]) + ) + await writeFile( + join(artifactDir, 'AuthAndPreflight.jsonl'), + jsonLines([ + { + type: 'session', + version: 3, + id: 'aaaaaaaa-bbbb-4ccc-8ddd-222222222222', + cwd: '/repo/app', + timestamp: '2026-05-01T10:01:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:01:01.000Z', + message: { role: 'user', content: 'Subagent task prompt' } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.issues).toEqual([]) + // Only the coordinator surfaces; the task child is not a row, but it is + // still counted for the row's "N subagents" affordance (#9330). + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + sessionId: SESSION_ID, + subagentTranscriptCount: 1 + }) + }) + + it('still scans a workspace dir whose name looks like a session stem', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-depth-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + // Depth 0 is the workspace dir. Pruning by name alone would hide every + // session in a workspace that happened to be named like a session stem. + const workspaceDir = join(roots.ompSessionsDir, SESSION_STEM) + await mkdir(workspaceDir, { recursive: true }) + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Hello from a stem-named workspace' } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions.map((entry) => entry.sessionId)).toContain(SESSION_ID) + }) + + it('picks up a task transcript written after the coordinator last changed', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-late-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspaceDir = join(roots.ompSessionsDir, 'home-app-85dfa2f0') + await mkdir(workspaceDir, { recursive: true }) + // A coordinator that persisted no conversation turns: its file never + // changes again, so only the cached-reuse recount can see a late child. + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + } + ]) + ) + + const first = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(first.sessions.find((entry) => entry.sessionId === SESSION_ID)).toMatchObject({ + messageCount: 0, + subagentTranscriptCount: 0 + }) + + await mkdir(join(workspaceDir, SESSION_STEM), { recursive: true }) + await writeFile( + join(workspaceDir, SESSION_STEM, 'AuthAndPreflight.jsonl'), + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:01:01.000Z', + message: { role: 'user', content: 'Late task prompt' } + } + ]) + ) + + const second = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect( + second.sessions.find((entry) => entry.sessionId === SESSION_ID)?.subagentTranscriptCount + ).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts new file mode 100644 index 00000000000..b8c802a021f --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' +import { + OMP_SESSION_ARTIFACT_DIR_PATTERN, + countOmpSubagentTranscripts, + partitionOmpSubagentTranscriptPaths +} from './session-scanner-omp-subagent-transcripts' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeWorkspaceDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-omp-subagents-')) + tempRoots.push(root) + return root +} + +describe('OMP_SESSION_ARTIFACT_DIR_PATTERN', () => { + it('matches session artifact dir names and nothing else in the layout', () => { + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test(SESSION_STEM)).toBe(true) + // Task children are label-named. + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test('AuthAndPreflight')).toBe(false) + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test('local')).toBe(false) + // Workspace dir names OMP encodes from a cwd (relative to home, relative to + // tmp, absolute, or slug-hashed) never collide with a session stem. The + // local prune doesn't depend on this — it skips depth 0 — but the remote + // partition has no root to measure depth against, so it does. + for (const workspaceDirName of [ + `-${SESSION_STEM}`, + `-tmp-${SESSION_STEM}`, + `--${SESSION_STEM}--`, + 'home-app-85dfa2f063812c58976deb581167a634a4' + ]) { + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test(workspaceDirName)).toBe(false) + } + }) +}) + +describe('countOmpSubagentTranscripts', () => { + it('counts only direct-child transcripts of the artifact dir', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + const artifactDir = join(workspace, SESSION_STEM) + await mkdir(join(artifactDir, 'local'), { recursive: true }) + await writeFile(parentPath, '') + await writeFile(join(artifactDir, 'AuthAndPreflight.jsonl'), '') + await writeFile(join(artifactDir, 'BitbucketDcApi.jsonl'), '') + // Artifacts are not transcripts; nested files belong to their own parents. + await writeFile(join(artifactDir, 'notes.md'), '') + await writeFile(join(artifactDir, 'local', 'plan.jsonl'), '') + + await expect(countOmpSubagentTranscripts(parentPath)).resolves.toBe(2) + }) + + it('returns 0 when the session never delegated (no artifact dir)', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + await writeFile(parentPath, '') + + await expect(countOmpSubagentTranscripts(parentPath)).resolves.toBe(0) + }) +}) + +describe('partitionOmpSubagentTranscriptPaths', () => { + it('drops artifact-dir transcripts from candidates and counts them per parent', () => { + const workspace = `/home/user/.omp/agent/sessions/home-app-85dfa2f0` + const parent = `${workspace}/${SESSION_STEM}.jsonl` + const sibling = `${workspace}/2026-05-02T09-00-00-000Z_dddddddd-eeee-4fff-8aaa-111111111111.jsonl` + const partition = partitionOmpSubagentTranscriptPaths([ + parent, + `${workspace}/${SESSION_STEM}/AuthAndPreflight.jsonl`, + `${workspace}/${SESSION_STEM}/BitbucketDcApi.jsonl`, + // A grandchild attributes to its own parent, not the top-level session — + // and never surfaces as a candidate. + `${workspace}/${SESSION_STEM}/AuthAndPreflight/Nested.jsonl`, + sibling + ]) + + expect(partition.sessionFilePaths).toEqual([parent, sibling]) + expect(partition.subagentTranscriptCounts.get(parent)).toBe(2) + expect(partition.subagentTranscriptCounts.size).toBe(1) + }) + + it('attributes a child to its nearest stamped ancestor, not the outermost one', () => { + const workspace = '/home/user/.omp/agent/sessions/home-app-85dfa2f0' + const nested = '2026-05-02T09-00-00-000Z_dddddddd-eeee-4fff-8aaa-111111111111' + const partition = partitionOmpSubagentTranscriptPaths([ + `${workspace}/${SESSION_STEM}/${nested}/Grandchild.jsonl` + ]) + + expect(partition.sessionFilePaths).toEqual([]) + expect([...partition.subagentTranscriptCounts]).toEqual([ + [`${workspace}/${SESSION_STEM}/${nested}.jsonl`, 1] + ]) + }) + + it('handles Windows separators', () => { + const workspace = `C:\\Users\\u\\.omp\\agent\\sessions\\home-app-85dfa2f0` + const parent = `${workspace}\\${SESSION_STEM}.jsonl` + const partition = partitionOmpSubagentTranscriptPaths([ + parent, + `${workspace}\\${SESSION_STEM}\\AuthAndPreflight.jsonl` + ]) + + expect(partition.sessionFilePaths).toEqual([parent]) + expect(partition.subagentTranscriptCounts.get(parent)).toBe(1) + }) +}) + +describe('withOmpSubagentTranscriptCount host gating', () => { + const content = JSON.stringify({ + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }) + + it('never counts a local artifact dir for a remote-host transcript', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + // A local artifact dir exists at the same path, but the content came from + // an SSH host — its real task children live on that host, not on this disk. + await mkdir(join(workspace, SESSION_STEM), { recursive: true }) + await writeFile(join(workspace, SESSION_STEM, 'AuthAndPreflight.jsonl'), '') + const file = { path: parentPath, mtimeMs: 0, modifiedAt: '2026-05-01T10:00:00.000Z' } + + const remote = await parseMessageGraphSessionContent('omp', file, content, 'linux', { + executionHostId: 'ssh:host' + }) + const local = await parseMessageGraphSessionContent('omp', file, content, 'darwin') + + expect(remote?.subagentTranscriptCount).toBe(0) + expect(local?.subagentTranscriptCount).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts new file mode 100644 index 00000000000..9136e8c0d34 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts @@ -0,0 +1,119 @@ +import { readdir } from 'node:fs/promises' +import { basename, dirname, extname, join } from 'node:path' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts' +import type { ResumableSessionParseState } from './session-scanner-types' + +// OMP names every session `_` and stores that session's +// artifacts — including its task-subagent transcripts — in a sibling directory +// of the same name (…//_.jsonl + …/_/). +// The anchored name pattern is the classification signal: task-child +// transcripts are label-named, so they can't match. Workspace dirs are encoded +// from a cwd and can't realistically match either, but the local prune doesn't +// rely on that — it skips depth 0 outright. This deliberately reads structure +// rather than a transcript's own records: children carry their own session id +// and no parentSession field at all, so lineage is not available here (#9330). +const OMP_SESSION_DIR_NAME = String.raw`\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` + +// Exported so discovery prunes exactly the subtrees this module reads on +// demand — the pruning predicate and the lister can't drift apart. +export const OMP_SESSION_ARTIFACT_DIR_PATTERN = new RegExp(`^${OMP_SESSION_DIR_NAME}$`, 'i') + +// Direct child of a session artifact dir: `…/_/